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`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith( Number, ), isLiked: userId ? sql`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})` : sql`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`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith( Number, ), isLiked: userId ? sql`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})` : sql`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`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) { 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(); } }