Browse Source

feat(comments): service for tree listing, create, and soft delete

Made-with: Cursor
main
npmrun 7 hours ago
parent
commit
155f4824f0
  1. 227
      server/service/post-comments/index.ts

227
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<ListCommentsPayload> {
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<number, CommentTreeNode>();
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<number> {
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<void> {
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));
}
Loading…
Cancel
Save