- Introduced `PATCH /:id/admin` endpoint to update admin-specific content. - Added `update` method to `ContentsRepository` for data updates with timestamp.
437 lines
9.8 KiB
TypeScript
437 lines
9.8 KiB
TypeScript
import { Injectable } from "@nestjs/common";
|
|
import {
|
|
and,
|
|
desc,
|
|
eq,
|
|
exists,
|
|
ilike,
|
|
isNull,
|
|
lte,
|
|
type SQL,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import { DatabaseService } from "../../database/database.service";
|
|
import {
|
|
categories,
|
|
contents,
|
|
contentsToTags,
|
|
favorites,
|
|
tags,
|
|
users,
|
|
} from "../../database/schemas";
|
|
import type { NewContentInDb } from "../../database/schemas/content";
|
|
|
|
export interface FindAllOptions {
|
|
limit: number;
|
|
offset: number;
|
|
sortBy?: "trend" | "recent";
|
|
tag?: string;
|
|
category?: string;
|
|
author?: string;
|
|
query?: string;
|
|
favoritesOnly?: boolean;
|
|
userId?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ContentsRepository {
|
|
constructor(private readonly databaseService: DatabaseService) {}
|
|
|
|
async findAll(options: FindAllOptions) {
|
|
const {
|
|
limit,
|
|
offset,
|
|
sortBy,
|
|
tag,
|
|
category,
|
|
author,
|
|
query,
|
|
favoritesOnly,
|
|
userId,
|
|
} = options;
|
|
|
|
let whereClause: SQL | undefined = isNull(contents.deletedAt);
|
|
|
|
if (tag) {
|
|
whereClause = and(
|
|
whereClause,
|
|
exists(
|
|
this.databaseService.db
|
|
.select()
|
|
.from(contentsToTags)
|
|
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
|
.where(
|
|
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (category) {
|
|
whereClause = and(
|
|
whereClause,
|
|
exists(
|
|
this.databaseService.db
|
|
.select()
|
|
.from(categories)
|
|
.where(
|
|
and(
|
|
eq(contents.categoryId, categories.id),
|
|
sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (author) {
|
|
whereClause = and(
|
|
whereClause,
|
|
exists(
|
|
this.databaseService.db
|
|
.select()
|
|
.from(users)
|
|
.where(
|
|
and(
|
|
eq(contents.userId, users.uuid),
|
|
sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (query) {
|
|
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
|
|
}
|
|
|
|
if (favoritesOnly && userId) {
|
|
whereClause = and(
|
|
whereClause,
|
|
exists(
|
|
this.databaseService.db
|
|
.select()
|
|
.from(favorites)
|
|
.where(
|
|
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
let orderBy = desc(contents.createdAt);
|
|
if (sortBy === "trend") {
|
|
orderBy = desc(sql`${contents.views} + ${contents.usageCount} * 2`);
|
|
}
|
|
|
|
const results = await this.databaseService.db
|
|
.select({
|
|
id: contents.id,
|
|
title: contents.title,
|
|
slug: contents.slug,
|
|
type: contents.type,
|
|
storageKey: contents.storageKey,
|
|
mimeType: contents.mimeType,
|
|
fileSize: contents.fileSize,
|
|
views: contents.views,
|
|
usageCount: contents.usageCount,
|
|
favoritesCount:
|
|
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
|
|
Number,
|
|
),
|
|
isLiked: userId
|
|
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
|
|
: sql<boolean>`false`,
|
|
createdAt: contents.createdAt,
|
|
updatedAt: contents.updatedAt,
|
|
author: {
|
|
id: users.uuid,
|
|
username: users.username,
|
|
displayName: users.displayName,
|
|
avatarUrl: users.avatarUrl,
|
|
},
|
|
category: {
|
|
id: categories.id,
|
|
name: categories.name,
|
|
slug: categories.slug,
|
|
},
|
|
})
|
|
.from(contents)
|
|
.leftJoin(users, eq(contents.userId, users.uuid))
|
|
.leftJoin(categories, eq(contents.categoryId, categories.id))
|
|
.where(whereClause)
|
|
.orderBy(orderBy)
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
const contentIds = results.map((r) => r.id);
|
|
const tagsForContents = contentIds.length
|
|
? await this.databaseService.db
|
|
.select({
|
|
contentId: contentsToTags.contentId,
|
|
name: tags.name,
|
|
})
|
|
.from(contentsToTags)
|
|
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
|
.where(sql`${contentsToTags.contentId} IN ${contentIds}`)
|
|
: [];
|
|
|
|
return results.map((r) => ({
|
|
...r,
|
|
tags: tagsForContents.filter((t) => t.contentId === r.id).map((t) => t.name),
|
|
}));
|
|
}
|
|
|
|
async create(data: NewContentInDb & { userId: string }, tagNames?: string[]) {
|
|
return await this.databaseService.db.transaction(async (tx) => {
|
|
const [newContent] = await tx.insert(contents).values(data).returning();
|
|
|
|
if (tagNames && tagNames.length > 0) {
|
|
for (const tagName of tagNames) {
|
|
const slug = tagName
|
|
.toLowerCase()
|
|
.replace(/ /g, "-")
|
|
.replace(/[^\w-]/g, "");
|
|
|
|
let [tag] = await tx
|
|
.select()
|
|
.from(tags)
|
|
.where(eq(tags.slug, slug))
|
|
.limit(1);
|
|
|
|
if (!tag) {
|
|
[tag] = await tx
|
|
.insert(tags)
|
|
.values({
|
|
name: tagName,
|
|
slug,
|
|
userId: data.userId,
|
|
})
|
|
.returning();
|
|
}
|
|
|
|
await tx
|
|
.insert(contentsToTags)
|
|
.values({
|
|
contentId: newContent.id,
|
|
tagId: tag.id,
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
}
|
|
|
|
return newContent;
|
|
});
|
|
}
|
|
|
|
async findOne(idOrSlug: string, userId?: string) {
|
|
const [result] = await this.databaseService.db
|
|
.select({
|
|
id: contents.id,
|
|
title: contents.title,
|
|
slug: contents.slug,
|
|
type: contents.type,
|
|
storageKey: contents.storageKey,
|
|
mimeType: contents.mimeType,
|
|
fileSize: contents.fileSize,
|
|
views: contents.views,
|
|
usageCount: contents.usageCount,
|
|
favoritesCount:
|
|
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
|
|
Number,
|
|
),
|
|
isLiked: userId
|
|
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
|
|
: sql<boolean>`false`,
|
|
createdAt: contents.createdAt,
|
|
updatedAt: contents.updatedAt,
|
|
userId: contents.userId,
|
|
author: {
|
|
id: users.uuid,
|
|
username: users.username,
|
|
displayName: users.displayName,
|
|
avatarUrl: users.avatarUrl,
|
|
},
|
|
category: {
|
|
id: categories.id,
|
|
name: categories.name,
|
|
slug: categories.slug,
|
|
},
|
|
})
|
|
.from(contents)
|
|
.leftJoin(users, eq(contents.userId, users.uuid))
|
|
.leftJoin(categories, eq(contents.categoryId, categories.id))
|
|
.where(
|
|
and(
|
|
isNull(contents.deletedAt),
|
|
sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`,
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
if (!result) return null;
|
|
|
|
const tagsForContent = await this.databaseService.db
|
|
.select({
|
|
name: tags.name,
|
|
})
|
|
.from(contentsToTags)
|
|
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
|
.where(eq(contentsToTags.contentId, result.id));
|
|
|
|
return {
|
|
...result,
|
|
tags: tagsForContent.map((t) => t.name),
|
|
};
|
|
}
|
|
|
|
async count(options: {
|
|
tag?: string;
|
|
category?: string;
|
|
author?: string;
|
|
query?: string;
|
|
favoritesOnly?: boolean;
|
|
userId?: string;
|
|
}) {
|
|
const { tag, category, author, query, favoritesOnly, userId } = options;
|
|
|
|
let whereClause: SQL | undefined = isNull(contents.deletedAt);
|
|
|
|
if (tag) {
|
|
whereClause = and(
|
|
whereClause,
|
|
exists(
|
|
this.databaseService.db
|
|
.select()
|
|
.from(contentsToTags)
|
|
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
|
.where(
|
|
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (category) {
|
|
whereClause = and(
|
|
whereClause,
|
|
exists(
|
|
this.databaseService.db
|
|
.select()
|
|
.from(categories)
|
|
.where(
|
|
and(
|
|
eq(contents.categoryId, categories.id),
|
|
sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (author) {
|
|
whereClause = and(
|
|
whereClause,
|
|
exists(
|
|
this.databaseService.db
|
|
.select()
|
|
.from(users)
|
|
.where(
|
|
and(
|
|
eq(contents.userId, users.uuid),
|
|
sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (query) {
|
|
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
|
|
}
|
|
|
|
if (favoritesOnly && userId) {
|
|
whereClause = and(
|
|
whereClause,
|
|
exists(
|
|
this.databaseService.db
|
|
.select()
|
|
.from(favorites)
|
|
.where(
|
|
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
const [result] = await this.databaseService.db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(contents)
|
|
.where(whereClause);
|
|
|
|
return Number(result.count);
|
|
}
|
|
|
|
async incrementViews(id: string) {
|
|
await this.databaseService.db
|
|
.update(contents)
|
|
.set({ views: sql`${contents.views} + 1` })
|
|
.where(eq(contents.id, id));
|
|
}
|
|
|
|
async incrementUsage(id: string) {
|
|
await this.databaseService.db
|
|
.update(contents)
|
|
.set({ usageCount: sql`${contents.usageCount} + 1` })
|
|
.where(eq(contents.id, id));
|
|
}
|
|
|
|
async softDelete(id: string, userId: string) {
|
|
const [deleted] = await this.databaseService.db
|
|
.update(contents)
|
|
.set({ deletedAt: new Date() })
|
|
.where(and(eq(contents.id, id), eq(contents.userId, userId)))
|
|
.returning();
|
|
return deleted;
|
|
}
|
|
|
|
async softDeleteAdmin(id: string) {
|
|
const [deleted] = await this.databaseService.db
|
|
.update(contents)
|
|
.set({ deletedAt: new Date() })
|
|
.where(eq(contents.id, id))
|
|
.returning();
|
|
return deleted;
|
|
}
|
|
|
|
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
|
|
const [updated] = await this.databaseService.db
|
|
.update(contents)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(contents.id, id))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
|
|
async findBySlug(slug: string) {
|
|
const [result] = await this.databaseService.db
|
|
.select()
|
|
.from(contents)
|
|
.where(eq(contents.slug, slug))
|
|
.limit(1);
|
|
return result;
|
|
}
|
|
|
|
async purgeSoftDeleted(before: Date) {
|
|
return await this.databaseService.db
|
|
.delete(contents)
|
|
.where(
|
|
and(
|
|
sql`${contents.deletedAt} IS NOT NULL`,
|
|
lte(contents.deletedAt, before),
|
|
),
|
|
)
|
|
.returning();
|
|
}
|
|
}
|