diff --git a/server/service/post-comments/index.ts b/server/service/post-comments/index.ts new file mode 100644 index 0000000..4017e6e --- /dev/null +++ b/server/service/post-comments/index.ts @@ -0,0 +1,227 @@ +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)); +}