1 changed files with 227 additions and 0 deletions
@ -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…
Reference in new issue