import "server-only"; import { notifyCommentChange } from "./events"; import { and, asc, desc, eq, inArray, isNull, lt, or, sql, type SQL } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { getDb, type Database } from "@/db"; import { commentThreads, comments, commentRevisions, commentAttachments, commentRevisionAttachments, commentReactions, catalogCharacters, guides, stygianSchedules, users, notifications } from "@/db/schema"; import { notifyNotificationChange } from "@/lib/notifications/events"; import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage"; import { getCustomerSession } from "@/lib/auth/server"; import { isAuthorizedAdmin } from "@/lib/auth/authorization"; import { auditActor, writeAuditLog } from "@/lib/audit-log"; import { HttpError } from "@/lib/security/http"; import { decodeCursor, encodeCursor, parseTarget, uuidSchema } from "./validation"; import type { CommentHistoryItem, CommentImage, CommentItem, CommentPage, CommentViewer } from "./types"; type Reader = Pick; export async function getCommentViewer(): Promise { const session = await getCustomerSession(); if (!session) return null; const [user] = await getDb().select().from(users).where(eq(users.id, session.user.id)).limit(1); if (!user || user.banned) return null; return { id: user.id, admin: isAuthorizedAdmin(user), name: user.name, image: user.image }; } export async function requireCommentViewer() { const viewer = await getCommentViewer(); if (!viewer) throw new HttpError(401, "sign-in-required"); return viewer; } export function requireCommentAdmin(viewer: CommentViewer | null): asserts viewer is CommentViewer { if (!viewer?.admin) throw new HttpError(403, "admin-required"); } export function commentId(value: string) { if (!uuidSchema.safeParse(value).success) throw new HttpError(404, "comment-not-found"); return value; } export async function getCommentTarget(target: string, viewer: CommentViewer | null, db: Reader = getDb()) { const parsed = parseTarget(target); if (parsed.kind === "guide") { const [guide] = await db.select({ id: guides.id, name: guides.name, slug: guides.slug, public: guides.isPublic, trashedAt: guides.trashedAt }) .from(guides).where(eq(guides.id, parsed.id)).limit(1); if (!guide || (!viewer?.admin && (!guide.public || guide.trashedAt))) throw new HttpError(404, "guide-not-found"); return { target: `guide:${guide.id}`, name: guide.name, href: `/${guide.slug}/comment`, guideId: guide.id, scheduleId: null, writable: !guide.trashedAt }; } const [schedule] = await db.select({ id: stygianSchedules.scheduleId, name: stygianSchedules.challengeName }) .from(stygianSchedules).where(eq(stygianSchedules.scheduleId, parsed.id)).limit(1); if (!schedule) throw new HttpError(404, "schedule-not-found"); return { target: `stygian:${schedule.id}`, name: `Stygian ${schedule.name}`, href: `/stygian/comment?schedule=${schedule.id}`, guideId: null, scheduleId: schedule.id, writable: true }; } export async function findCommentThread(target: string, viewer: CommentViewer | null) { const destination = await getCommentTarget(target, viewer); const [thread] = await getDb().select().from(commentThreads).where(destination.guideId ? eq(commentThreads.guideId, destination.guideId) : eq(commentThreads.scheduleId, destination.scheduleId!)).limit(1); return { destination, thread }; } export async function ensureCommentThread(target: string, viewer: CommentViewer) { const destination = await getCommentTarget(target, viewer); if (!destination.writable) throw new HttpError(409, "guide-trashed"); await getDb().insert(commentThreads).values({ guideId: destination.guideId, scheduleId: destination.scheduleId }).onConflictDoNothing(); return (await findCommentThread(target, viewer)).thread!; } export async function authorizeComment(id: string, viewer: CommentViewer | null, db: Reader = getDb()) { commentId(id); const [row] = await db.select({ comment: comments, thread: commentThreads }).from(comments) .innerJoin(commentThreads, eq(commentThreads.id, comments.threadId)).where(eq(comments.id, id)).limit(1); if (!row) throw new HttpError(404, "comment-not-found"); const target = row.thread.guideId ? `guide:${row.thread.guideId}` : `stygian:${row.thread.scheduleId}`; const destination = await getCommentTarget(target, viewer, db); const [root] = row.comment.rootId ? await db.select({ hidden: comments.hidden }).from(comments).where(eq(comments.id, row.comment.rootId)).limit(1) : []; if (!viewer?.admin && (row.comment.hidden || root?.hidden)) throw new HttpError(404, "comment-not-found"); return { ...row, destination, rootHidden: root?.hidden ?? false }; } const root = alias(comments, "discussion_root"); const parent = alias(comments, "discussion_parent"); const recipient = alias(users, "discussion_recipient"); const likes = sql`(select count(*)::int from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.value} = 1)`; const replyCount = sql`(select count(*)::int from ${comments} replies where replies.root_id = ${comments.id})`; const publicTarget = sql`(${commentThreads.guideId} is null or (${guides.isPublic} and ${guides.trashedAt} is null))`; async function revisionImages(ids: string[], db: Reader = getDb()) { const result = new Map(); if (!ids.length) return result; const rows = await db.select({ revisionId: commentRevisionAttachments.revisionId, id: commentAttachments.id, objectKey: commentAttachments.objectKey }) .from(commentRevisionAttachments).innerJoin(commentAttachments, eq(commentAttachments.id, commentRevisionAttachments.attachmentId)) .where(inArray(commentRevisionAttachments.revisionId, ids)).orderBy(asc(commentRevisionAttachments.position)); for (const row of rows) { const group = result.get(row.revisionId) ?? []; group.push({ id: row.id, url: publicMediaUrl(row.objectKey) }); result.set(row.revisionId, group); } return result; } export async function listComments(options: { viewer: CommentViewer | null; target?: string; rootId?: string; id?: string; cursor?: string | null; sort?: string; inbox?: boolean; grouped?: boolean; status?: string; unanswered?: boolean; }): Promise { const { viewer } = options; if (options.inbox) requireCommentAdmin(viewer); const grouped = Boolean(options.inbox && options.grouped && !options.id && !options.rootId); const conditions: SQL[] = []; if (options.id) conditions.push(eq(comments.id, commentId(options.id))); if (options.target) { const { thread } = await findCommentThread(options.target, viewer); if (!thread) return { items: [], nextCursor: null, viewer }; conditions.push(eq(comments.threadId, thread.id)); } else if (!options.inbox && !options.rootId) throw new HttpError(400, "target-required"); if (options.rootId) { const context = await authorizeComment(options.rootId, viewer); if (context.comment.rootId) throw new HttpError(400, "root-required"); if (options.target && context.destination.target !== options.target) throw new HttpError(404, "comment-not-found"); conditions.push(eq(comments.rootId, options.rootId)); } else if (grouped || (!options.inbox && !options.id)) conditions.push(isNull(comments.rootId)); if (!viewer?.admin) { conditions.push(sql`coalesce(${root.hidden}, false) = false`, publicTarget); if (!options.rootId) conditions.push(eq(comments.hidden, false)); if (!options.rootId && !options.id) conditions.push(or(isNull(comments.deletedAt), sql`${replyCount} > 0`)!); } if (grouped) { if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), sql`exists ( select 1 from ${comments} reply where reply.root_id = ${comments.id} and reply.hidden)`)!); if (options.status === "visible") conditions.push(eq(comments.hidden, false), or(isNull(comments.deletedAt), sql`exists ( select 1 from ${comments} reply where reply.root_id = ${comments.id} and not reply.hidden and reply.deleted_at is null)`)!); } else { if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), eq(root.hidden, true))!); if (options.status === "visible") conditions.push(eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`, isNull(comments.deletedAt)); } if (options.unanswered) conditions.push(isNull(comments.rootId), isNull(comments.deletedAt), sql`not exists ( select 1 from ${comments} reply join ${users} author on author.id = reply.author_id where reply.root_id = ${comments.id} and author.role = 'admin' and author.email_verified and not reply.hidden and reply.deleted_at is null)`); const cursor = decodeCursor(options.cursor ?? null); const top = !options.inbox && !options.rootId && options.sort === "top"; const ascending = Boolean(options.rootId); const timeBefore = cursor ? sql`(${comments.createdAt}, ${comments.id}) < (${cursor.time}::timestamptz, ${cursor.id}::uuid)` : undefined; if (cursor) conditions.push(top ? or(sql`${likes} < ${cursor.likes}`, and(sql`${likes} = ${cursor.likes}`, timeBefore))! : ascending ? sql`(${comments.createdAt}, ${comments.id}) > (${cursor.time}::timestamptz, ${cursor.id}::uuid)` : timeBefore!); const rows = await getDb().select({ cursorTime: sql`to_char(${comments.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, comment: comments, revision: commentRevisions, authorName: users.name, authorImage: users.image, authorAdmin: sql`coalesce(${users.role} = 'admin' and ${users.emailVerified}, false)`, replyToName: recipient.name, rootHidden: root.hidden, likes, replyCount, hasReplies: sql`exists(select 1 from ${comments} children where children.root_id = coalesce(${comments.rootId}, ${comments.id}) and children.reply_to_id = ${comments.id} and children.deleted_at is null)`, reaction: viewer ? sql`coalesce((select value from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.userId} = ${viewer.id}), 0)` : sql`0`, guideId: commentThreads.guideId, scheduleId: commentThreads.scheduleId, guideName: guides.name, guideCoverId: guides.coverMediaId, guideSlug: guides.slug, guideTrashedAt: guides.trashedAt, scheduleName: stygianSchedules.challengeName, publicTarget, }).from(comments) .innerJoin(commentThreads, eq(commentThreads.id, comments.threadId)) .innerJoin(commentRevisions, and(eq(commentRevisions.commentId, comments.id), eq(commentRevisions.version, comments.version))) .innerJoin(users, eq(users.id, comments.authorId)) .leftJoin(root, eq(root.id, comments.rootId)).leftJoin(parent, eq(parent.id, comments.replyToId)).leftJoin(recipient, eq(recipient.id, parent.authorId)) .leftJoin(guides, eq(guides.id, commentThreads.guideId)).leftJoin(stygianSchedules, eq(stygianSchedules.scheduleId, commentThreads.scheduleId)) .where(and(...conditions)).orderBy(...(top ? [desc(likes)] : []), ascending ? asc(comments.createdAt) : desc(comments.createdAt), ascending ? asc(comments.id) : desc(comments.id)).limit(21); const page = rows.slice(0, 20); const images = await revisionImages(page.filter((row) => viewer?.admin || (!row.comment.deletedAt && !row.comment.hidden)).map((row) => row.revision.id)); const items: CommentItem[] = page.map((row) => { const c = row.comment; const hidden = c.hidden || Boolean(row.rootHidden); const contentAllowed = viewer?.admin || (!c.deletedAt && !hidden); return { id: c.id, rootId: c.rootId, replyToId: c.replyToId, replyToName: row.replyToName, authorName: row.authorName, authorImage: row.authorImage, authorAdmin: row.authorAdmin, targetImage: row.guideCoverId ? `/media/${row.guideCoverId}` : null, text: contentAllowed ? row.revision.text : "", images: contentAllowed ? images.get(row.revision.id) ?? [] : [], version: c.version, createdAt: c.createdAt.toISOString(), editedAt: row.revision.createdAt.toISOString(), hidden, ownHidden: c.hidden, deleted: Boolean(c.deletedAt), hearted: Boolean(c.heartedById), likes: Number(row.likes), reaction: Number(row.reaction), replyCount: Number(row.replyCount), hasReplies: row.hasReplies, canEdit: viewer?.id === c.authorId && !c.deletedAt && !hidden && !row.guideTrashedAt, canModerate: Boolean(viewer?.admin) && !row.guideTrashedAt, canInteract: !c.deletedAt && !hidden && !row.guideTrashedAt && (row.publicTarget || Boolean(viewer?.admin)), target: row.guideId ? `guide:${row.guideId}` : `stygian:${row.scheduleId}`, targetName: row.guideName ?? `Stygian ${row.scheduleName}`, href: `${row.guideSlug ? `/${row.guideSlug}/comment` : `/stygian/comment?schedule=${row.scheduleId}`}#comment-${c.rootId ?? c.id}`, }; }); const last = page.at(-1); return { items, nextCursor: rows.length > 20 && last ? encodeCursor({ time: last.cursorTime, id: last.comment.id, likes: Number(last.likes) }) : null, viewer }; } export async function commentHistory(id: string, viewer: CommentViewer | null, cursor: string | null = null) { const context = await authorizeComment(id, viewer); if (context.comment.deletedAt && !viewer?.admin) throw new HttpError(404, "comment-not-found"); const before = cursor ? Number(cursor) : context.comment.version + 1; if (!Number.isSafeInteger(before) || before < 1) throw new HttpError(400, "invalid-cursor"); const rows = await getDb().select().from(commentRevisions).where(and(eq(commentRevisions.commentId, id), lt(commentRevisions.version, before))) .orderBy(desc(commentRevisions.version)).limit(21); const page = rows.slice(0, 20); const images = await revisionImages(page.map((revision) => revision.id)); return { items: page.map((revision): CommentHistoryItem => ({ id: revision.id, version: revision.version, text: revision.text, createdAt: revision.createdAt.toISOString(), images: images.get(revision.id) ?? [] })), nextCursor: rows.length > 20 ? String(page.at(-1)!.version) : null }; } /** All discussion mutations lock the thread first, including hide/delete and replies. */ export async function withCommentLock(id: string, viewer: CommentViewer, task: (db: Reader, context: Awaited>) => Promise) { const initial = await authorizeComment(id, viewer); return getDb().transaction(async (tx) => { await tx.select({ id: commentThreads.id }).from(commentThreads).where(eq(commentThreads.id, initial.thread.id)).for("update"); const context = await authorizeComment(id, viewer, tx); if (!context.destination.writable) throw new HttpError(409, "guide-trashed"); return task(tx, context); }); } export async function mutateComment(id: string, viewer: CommentViewer, action: "delete" | "reaction" | "moderation" | "heart", value?: number | boolean) { let target = ""; let deletedImages: { objectKey: string }[] = []; let notificationUsers: { userId: string }[] = []; const result = await withCommentLock(id, viewer, async (tx, context) => { target = context.destination.target; const c = context.comment; if (action === "moderation") { requireCommentAdmin(viewer); notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications) .innerJoin(comments, eq(comments.id, notifications.commentId)).where(or(eq(comments.id, id), eq(comments.rootId, id))); await tx.update(comments).set({ hidden: value as boolean }).where(eq(comments.id, id)); const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id)); await writeAuditLog(tx, auditActor(actor), { action: value ? "comment.hidden" : "comment.restored", targetType: "comment", targetId: id, metadata: { discussionTarget: context.destination.target } }); return; } if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable"); if (action === "delete") { if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author"); const descendants = sql`with recursive descendants(id) as ( select ${comments.id} from ${comments} where ${comments.id} = ${id} and ${comments.threadId} = ${c.threadId} union select child.id from ${comments} child join descendants parent on child.reply_to_id = parent.id where child.thread_id = ${c.threadId} ) select id from descendants`; deletedImages = await tx.select({ objectKey: commentAttachments.objectKey }).from(commentAttachments) .where(sql`${commentAttachments.commentId} in (${descendants})`); notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications) .where(sql`${notifications.commentId} in (${descendants})`); // Delete the entire subtree in one statement so self-referencing foreign keys remain valid. // Revisions, attachment metadata, revision links, and reactions cascade automatically. await tx.delete(comments).where(sql`${comments.id} in (${descendants})`); } else if (action === "heart") { requireCommentAdmin(viewer); await tx.update(comments).set({ heartedById: value ? viewer.id : null }).where(eq(comments.id, id)); if (value && !c.heartedById && c.authorId !== viewer.id) { const [author] = await tx.select({ id: users.id, email: users.email, role: users.role, emailVerified: users.emailVerified, banned: users.banned }) .from(users).where(eq(users.id, c.authorId)).limit(1); if (!author || author.banned) return; let destination; try { ({ destination } = await authorizeComment(id, { id: author.id, admin: isAuthorizedAdmin(author) }, tx)); } catch (cause) { if (cause instanceof HttpError && cause.status === 404) return; throw cause; } const url = `${destination.href}${c.rootId ? `${destination.href.includes("?") ? "&" : "?"}reply=${id}` : ""}#comment-${c.rootId ?? id}`; notificationUsers = await tx.insert(notifications).values({ userId: author.id, eventKey: `comment:${id}:heart`, kind: "comment_heart", title: "คุณได้รับหัวใจจากแอดมิน", body: `แอดมินให้หัวใจความคิดเห็นของคุณใน ${destination.name}`, url, commentId: id, }).onConflictDoNothing({ target: [notifications.userId, notifications.eventKey] }).returning({ userId: notifications.userId }); } } else if (value === 0) { await tx.delete(commentReactions).where(and(eq(commentReactions.commentId, id), eq(commentReactions.userId, viewer.id))); } else { await tx.insert(commentReactions).values({ commentId: id, userId: viewer.id, value: value as number }) .onConflictDoUpdate({ target: [commentReactions.commentId, commentReactions.userId], set: { value: value as number } }); } }); for (let offset = 0; offset < notificationUsers.length; offset += 10) await Promise.all(notificationUsers.slice(offset, offset + 10).map(user => notifyNotificationChange(user.userId))); await notifyCommentChange(target); if (deletedImages.length) { try { const storage = await getMediaStorage(); const cleanup = await Promise.allSettled(deletedImages.map((image) => storage.delete(image.objectKey))); if (cleanup.some((entry) => entry.status === "rejected")) console.error("Comment image cleanup failed"); } catch { console.error("Comment image cleanup failed"); } } return result; } export async function commentImage(id: string, viewer: CommentViewer | null) { commentId(id); const [image] = await getDb().select().from(commentAttachments).where(eq(commentAttachments.id, id)).limit(1); if (!image) throw new HttpError(404, "image-not-found"); const context = await authorizeComment(image.commentId, viewer); if (context.comment.deletedAt && !viewer?.admin) throw new HttpError(404, "image-not-found"); return image; } export async function listCommentTargets() { const [characters, schedules] = await Promise.all([ getDb().select({ id: guides.id, name: guides.name, characterKey: guides.characterKey, imageKey: catalogCharacters.imageKey, rarity: catalogCharacters.rarity }).from(guides).leftJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey)).orderBy(asc(guides.name)), getDb().select({ id: stygianSchedules.scheduleId, name: stygianSchedules.challengeName }).from(stygianSchedules).orderBy(desc(stygianSchedules.scheduleId)), ]); return [...characters.map((g) => ({ value: `guide:${g.id}`, label: g.name, image: g.imageKey ? publicMediaUrl(g.imageKey) : null, rarity: g.rarity, characterKey: g.characterKey })), ...schedules.map((s) => ({ value: `stygian:${s.id}`, label: `Stygian ${s.name}`, image: null, rarity: null, characterKey: null }))]; }