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