You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
227 lines
6.1 KiB
227 lines
6.1 KiB
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));
|
|
}
|
|
|