import { dbGlobal } from "drizzle-pkg/lib/db"; import { postComments, posts } from "drizzle-pkg/lib/schema/content"; import { users } from "drizzle-pkg/lib/schema/auth"; import { asc, eq } from "drizzle-orm"; import { nextIntegerId } from "#server/utils/sqlite-id"; import { GuestCommentValidationError, normalizeGuestDisplayName, validateGuestCommentBody, } from "#server/utils/post-comment-guest"; import type { MinimalUser } from "#server/service/auth"; export type CommentTreeNode = { id: number; parentId: number | null; kind: string; guestDisplayName: string | null; author: { nickname: string | null; username: string | null; publicSlug: string | null } | null; body: string | null; deleted: boolean; createdAt: string; children: CommentTreeNode[]; viewerCanDelete: boolean; }; export type ListCommentsPayload = { postId: number; viewerCanModeratePost: boolean; comments: CommentTreeNode[]; }; export async function listCommentsPayload( postId: number, ownerUserId: number, viewerUserId: number | null, ): Promise { const viewerCanModeratePost = viewerUserId != null && viewerUserId === ownerUserId; const rows = await dbGlobal .select({ id: postComments.id, postId: postComments.postId, parentId: postComments.parentId, authorUserId: postComments.authorUserId, guestDisplayName: postComments.guestDisplayName, body: postComments.body, kind: postComments.kind, deletedAt: postComments.deletedAt, createdAt: postComments.createdAt, authorNickname: users.nickname, authorUsername: users.username, authorPublicSlug: users.publicSlug, }) .from(postComments) .leftJoin(users, eq(postComments.authorUserId, users.id)) .where(eq(postComments.postId, postId)) .orderBy(asc(postComments.createdAt)); const byId = new Map(); for (const row of rows) { const deleted = row.deletedAt != null; let body: string | null = row.body; let guestDisplayName: string | null = row.guestDisplayName ?? null; let author: CommentTreeNode["author"] = null; let viewerCanDelete = false; if (deleted) { body = null; guestDisplayName = null; author = null; viewerCanDelete = false; } else { viewerCanDelete = viewerCanModeratePost || (row.kind === "user" && row.authorUserId != null && row.authorUserId === viewerUserId); if (row.kind === "user") { author = { nickname: row.authorNickname ?? null, username: row.authorUsername ?? null, publicSlug: row.authorPublicSlug ?? null, }; guestDisplayName = null; } else { author = null; } } byId.set(row.id, { id: row.id, parentId: row.parentId, kind: row.kind, guestDisplayName, author, body, deleted, createdAt: row.createdAt.toISOString(), children: [], viewerCanDelete, }); } const roots: CommentTreeNode[] = []; for (const row of rows) { const node = byId.get(row.id)!; if (row.parentId == null) { roots.push(node); } else { const parent = byId.get(row.parentId); if (parent) { parent.children.push(node); } } } return { postId, viewerCanModeratePost, comments: roots }; } const MAX_USER_COMMENT_BODY = 8000; export async function createComment(input: { postId: number; ownerUserId: number; parentId: number | null; viewer: MinimalUser | null; guestDisplayName?: string; body: string; }): Promise { if (input.parentId != null) { const [parent] = await dbGlobal .select() .from(postComments) .where(eq(postComments.id, input.parentId)) .limit(1); if (!parent || parent.postId !== input.postId || parent.deletedAt != null) { throw createError({ statusCode: 400, statusMessage: "无效的回复目标" }); } } let kind: string; let authorUserId: number | null; let guestDisplayName: string | null; let body: string; if (input.viewer != null) { body = input.body.trim(); if (!body) { throw createError({ statusCode: 400, statusMessage: "请填写内容" }); } if (body.length > MAX_USER_COMMENT_BODY) { throw createError({ statusCode: 400, statusMessage: "内容过长" }); } kind = "user"; authorUserId = input.viewer.id; guestDisplayName = null; } else { try { guestDisplayName = normalizeGuestDisplayName(input.guestDisplayName ?? ""); body = validateGuestCommentBody(input.body); } catch (e) { if (e instanceof GuestCommentValidationError) { throw createError({ statusCode: 400, statusMessage: e.message }); } throw e; } kind = "guest"; authorUserId = null; } const id = await nextIntegerId(postComments, postComments.id); await dbGlobal.insert(postComments).values({ id, postId: input.postId, parentId: input.parentId, authorUserId, guestDisplayName, body, kind, }); return id; } export async function softDeleteComment(input: { postId: number; commentId: number; actorUserId: number; }): Promise { const [post] = await dbGlobal .select({ userId: posts.userId }) .from(posts) .where(eq(posts.id, input.postId)) .limit(1); if (!post) { throw createError({ statusCode: 404, statusMessage: "未找到" }); } const [comment] = await dbGlobal .select() .from(postComments) .where(eq(postComments.id, input.commentId)) .limit(1); if (!comment || comment.postId !== input.postId) { throw createError({ statusCode: 404, statusMessage: "未找到" }); } if (comment.deletedAt != null) { throw createError({ statusCode: 400, statusMessage: "评论已删除" }); } const allowed = comment.authorUserId === input.actorUserId || post.userId === input.actorUserId; if (!allowed) { throw createError({ statusCode: 403, statusMessage: "无权删除" }); } await dbGlobal .update(postComments) .set({ deletedAt: new Date(), deletedByUserId: input.actorUserId, }) .where(eq(postComments.id, input.commentId)); }