Files
buzz-sheet/lib/comments/repository.ts
T
gunshiz b9601b739d
CI / Verify (push) Successful in 1m50s
CI / Build immutable images and deploy (push) Successful in 3m47s
feat : imrpove admin comment
2026-10-08 15:06:19 +07:00

318 lines
22 KiB
TypeScript

import "server-only";
import { notifyCommentChange } from "./events";
import { and, asc, desc, eq, inArray, isNull, lt, or, sql, type SQL } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { getDb, type Database } from "@/db";
import { commentThreads, comments, commentRevisions, commentAttachments, commentRevisionAttachments, commentReactions, catalogCharacters, guides, stygianSchedules, users, sessions, notifications } from "@/db/schema";
import { notifyNotificationChange } from "@/lib/notifications/events";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
import { getCustomerSession } from "@/lib/auth/server";
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
import { auditActor, writeAuditLog } from "@/lib/audit-log";
import { HttpError } from "@/lib/security/http";
import { decodeCursor, encodeCursor, parseTarget, uuidSchema } from "./validation";
import type { CommentHistoryItem, CommentImage, CommentItem, CommentPage, CommentViewer } from "./types";
type Reader = Pick<Database, "select" | "selectDistinct" | "insert" | "update" | "delete" | "execute">;
export async function getCommentViewer(): Promise<CommentViewer | null> {
const session = await getCustomerSession();
if (!session) return null;
const [user] = await getDb().select().from(users).where(eq(users.id, session.user.id)).limit(1);
if (!user || user.banned) return null;
return { id: user.id, admin: isAuthorizedAdmin(user), name: user.name, image: user.image };
}
export async function requireCommentViewer() {
const viewer = await getCommentViewer();
if (!viewer) throw new HttpError(401, "sign-in-required");
return viewer;
}
export function requireCommentAdmin(viewer: CommentViewer | null): asserts viewer is CommentViewer {
if (!viewer?.admin) throw new HttpError(403, "admin-required");
}
export function commentId(value: string) {
if (!uuidSchema.safeParse(value).success) throw new HttpError(404, "comment-not-found");
return value;
}
export async function getCommentTarget(target: string, viewer: CommentViewer | null, db: Reader = getDb()) {
const parsed = parseTarget(target);
if (parsed.kind === "guide") {
const [guide] = await db.select({ id: guides.id, name: guides.name, slug: guides.slug, public: guides.isPublic, trashedAt: guides.trashedAt })
.from(guides).where(eq(guides.id, parsed.id)).limit(1);
if (!guide || (!viewer?.admin && (!guide.public || guide.trashedAt))) throw new HttpError(404, "guide-not-found");
return { target: `guide:${guide.id}`, name: guide.name, href: `/${guide.slug}/comment`, guideId: guide.id, scheduleId: null, writable: !guide.trashedAt };
}
const [schedule] = await db.select({ id: stygianSchedules.scheduleId, name: stygianSchedules.challengeName })
.from(stygianSchedules).where(eq(stygianSchedules.scheduleId, parsed.id)).limit(1);
if (!schedule) throw new HttpError(404, "schedule-not-found");
return { target: `stygian:${schedule.id}`, name: `Stygian (${schedule.name})`, href: `/stygian/comment?schedule=${schedule.id}`, guideId: null, scheduleId: schedule.id, writable: true };
}
export async function findCommentThread(target: string, viewer: CommentViewer | null) {
const destination = await getCommentTarget(target, viewer);
const [thread] = await getDb().select().from(commentThreads).where(destination.guideId
? eq(commentThreads.guideId, destination.guideId) : eq(commentThreads.scheduleId, destination.scheduleId!)).limit(1);
return { destination, thread };
}
export async function ensureCommentThread(target: string, viewer: CommentViewer) {
const destination = await getCommentTarget(target, viewer);
if (!destination.writable) throw new HttpError(409, "guide-trashed");
await getDb().insert(commentThreads).values({ guideId: destination.guideId, scheduleId: destination.scheduleId }).onConflictDoNothing();
return (await findCommentThread(target, viewer)).thread!;
}
export async function authorizeComment(id: string, viewer: CommentViewer | null, db: Reader = getDb()) {
commentId(id);
const [row] = await db.select({ comment: comments, thread: commentThreads }).from(comments)
.innerJoin(commentThreads, eq(commentThreads.id, comments.threadId)).where(eq(comments.id, id)).limit(1);
if (!row) throw new HttpError(404, "comment-not-found");
const target = row.thread.guideId ? `guide:${row.thread.guideId}` : `stygian:${row.thread.scheduleId}`;
const destination = await getCommentTarget(target, viewer, db);
const [root] = row.comment.rootId ? await db.select({ hidden: comments.hidden }).from(comments).where(eq(comments.id, row.comment.rootId)).limit(1) : [];
if (!viewer?.admin && (row.comment.hidden || root?.hidden)) throw new HttpError(404, "comment-not-found");
return { ...row, destination, rootHidden: root?.hidden ?? false };
}
const root = alias(comments, "discussion_root");
const parent = alias(comments, "discussion_parent");
const recipient = alias(users, "discussion_recipient");
const likes = sql<number>`(select count(*)::int from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.value} = 1)`;
const replyCount = sql<number>`(select count(*)::int from ${comments} replies where replies.root_id = ${comments.id})`;
const publicTarget = sql<boolean>`(${commentThreads.guideId} is null or (${guides.isPublic} and ${guides.trashedAt} is null))`;
async function revisionImages(ids: string[], db: Reader = getDb()) {
const result = new Map<string, CommentImage[]>();
if (!ids.length) return result;
const rows = await db.select({ revisionId: commentRevisionAttachments.revisionId, id: commentAttachments.id, objectKey: commentAttachments.objectKey })
.from(commentRevisionAttachments).innerJoin(commentAttachments, eq(commentAttachments.id, commentRevisionAttachments.attachmentId))
.where(inArray(commentRevisionAttachments.revisionId, ids)).orderBy(asc(commentRevisionAttachments.position));
for (const row of rows) {
const group = result.get(row.revisionId) ?? [];
group.push({ id: row.id, url: publicMediaUrl(row.objectKey) });
result.set(row.revisionId, group);
}
return result;
}
export async function listComments(options: {
viewer: CommentViewer | null; target?: string; rootId?: string; id?: string; cursor?: string | null;
sort?: string; inbox?: boolean; grouped?: boolean; status?: string; unanswered?: boolean;
}): Promise<CommentPage> {
const { viewer } = options;
if (options.inbox) requireCommentAdmin(viewer);
const grouped = Boolean(options.inbox && options.grouped && !options.id && !options.rootId);
const conditions: SQL[] = [];
if (options.id) conditions.push(eq(comments.id, commentId(options.id)));
if (options.target) {
const { thread } = await findCommentThread(options.target, viewer);
if (!thread) return { items: [], nextCursor: null, viewer };
conditions.push(eq(comments.threadId, thread.id));
} else if (!options.inbox && !options.rootId) throw new HttpError(400, "target-required");
if (options.rootId) {
const context = await authorizeComment(options.rootId, viewer);
if (context.comment.rootId) throw new HttpError(400, "root-required");
if (options.target && context.destination.target !== options.target) throw new HttpError(404, "comment-not-found");
conditions.push(eq(comments.rootId, options.rootId));
} else if (grouped || (!options.inbox && !options.id)) conditions.push(isNull(comments.rootId));
if (!viewer?.admin) {
conditions.push(sql`coalesce(${root.hidden}, false) = false`, publicTarget);
if (!options.rootId) conditions.push(eq(comments.hidden, false));
if (!options.rootId && !options.id) conditions.push(or(isNull(comments.deletedAt), sql`${replyCount} > 0`)!);
}
if (grouped) {
if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), sql`exists (
select 1 from ${comments} reply where reply.root_id = ${comments.id} and reply.hidden)`)!);
if (options.status === "visible") conditions.push(eq(comments.hidden, false), or(isNull(comments.deletedAt), sql`exists (
select 1 from ${comments} reply where reply.root_id = ${comments.id} and not reply.hidden and reply.deleted_at is null)`)!);
} else {
if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), eq(root.hidden, true))!);
if (options.status === "visible") conditions.push(eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`, isNull(comments.deletedAt));
}
if (options.unanswered) conditions.push(isNull(comments.rootId), isNull(comments.deletedAt), sql`not exists (
select 1 from ${comments} reply join ${users} author on author.id = reply.author_id
where reply.root_id = ${comments.id} and author.role = 'admin' and author.email_verified and not reply.hidden and reply.deleted_at is null)`);
const cursor = decodeCursor(options.cursor ?? null);
const top = !options.inbox && !options.rootId && options.sort === "top";
const ascending = Boolean(options.rootId);
const timeBefore = cursor ? sql`(${comments.createdAt}, ${comments.id}) < (${cursor.time}::timestamptz, ${cursor.id}::uuid)` : undefined;
if (cursor) conditions.push(top
? or(sql`${likes} < ${cursor.likes}`, and(sql`${likes} = ${cursor.likes}`, timeBefore))!
: ascending ? sql`(${comments.createdAt}, ${comments.id}) > (${cursor.time}::timestamptz, ${cursor.id}::uuid)` : timeBefore!);
const rows = await getDb().select({
cursorTime: sql<string>`to_char(${comments.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
comment: comments, revision: commentRevisions, authorName: users.name, authorImage: users.image,
authorAdmin: sql<boolean>`coalesce(${users.role} = 'admin' and ${users.emailVerified}, false)`,
authorBanned: users.banned,
replyToName: recipient.name, rootHidden: root.hidden, likes, replyCount,
hasReplies: sql<boolean>`exists(select 1 from ${comments} children where children.root_id = coalesce(${comments.rootId}, ${comments.id}) and children.reply_to_id = ${comments.id} and children.deleted_at is null)`,
reaction: viewer ? sql<number>`coalesce((select value from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.userId} = ${viewer.id}), 0)` : sql<number>`0`,
guideId: commentThreads.guideId, scheduleId: commentThreads.scheduleId, guideName: guides.name, guideCoverId: guides.coverMediaId, guideSlug: guides.slug, guideTrashedAt: guides.trashedAt,
scheduleName: stygianSchedules.challengeName, publicTarget,
}).from(comments)
.innerJoin(commentThreads, eq(commentThreads.id, comments.threadId))
.innerJoin(commentRevisions, and(eq(commentRevisions.commentId, comments.id), eq(commentRevisions.version, comments.version)))
.innerJoin(users, eq(users.id, comments.authorId))
.leftJoin(root, eq(root.id, comments.rootId)).leftJoin(parent, eq(parent.id, comments.replyToId)).leftJoin(recipient, eq(recipient.id, parent.authorId))
.leftJoin(guides, eq(guides.id, commentThreads.guideId)).leftJoin(stygianSchedules, eq(stygianSchedules.scheduleId, commentThreads.scheduleId))
.where(and(...conditions)).orderBy(...(top ? [desc(likes)] : []), ascending ? asc(comments.createdAt) : desc(comments.createdAt), ascending ? asc(comments.id) : desc(comments.id)).limit(21);
const page = rows.slice(0, 20);
const images = await revisionImages(page.filter((row) => viewer?.admin || (!row.comment.deletedAt && !row.comment.hidden)).map((row) => row.revision.id));
const items: CommentItem[] = page.map((row) => {
const c = row.comment;
const hidden = c.hidden || Boolean(row.rootHidden);
const contentAllowed = viewer?.admin || (!c.deletedAt && !hidden);
return {
id: c.id, rootId: c.rootId, replyToId: c.replyToId, replyToName: row.replyToName,
authorName: row.authorName, authorImage: row.authorImage, authorAdmin: row.authorAdmin,
authorBanned: Boolean(viewer?.admin && row.authorBanned),
canBanAuthor: Boolean(viewer?.admin && viewer.id !== c.authorId && !row.authorAdmin && !row.guideTrashedAt),
targetImage: row.guideCoverId ? `/media/${row.guideCoverId}` : null,
text: contentAllowed ? row.revision.text : "", images: contentAllowed ? images.get(row.revision.id) ?? [] : [],
version: c.version, createdAt: c.createdAt.toISOString(), editedAt: row.revision.createdAt.toISOString(), hidden, ownHidden: c.hidden,
deleted: Boolean(c.deletedAt), hearted: Boolean(c.heartedById), likes: Number(row.likes), reaction: Number(row.reaction),
replyCount: Number(row.replyCount), hasReplies: row.hasReplies, canEdit: viewer?.id === c.authorId && !c.deletedAt && !hidden && !row.guideTrashedAt,
canModerate: Boolean(viewer?.admin) && !row.guideTrashedAt,
canInteract: !c.deletedAt && !hidden && !row.guideTrashedAt && (row.publicTarget || Boolean(viewer?.admin)),
target: row.guideId ? `guide:${row.guideId}` : `stygian:${row.scheduleId}`,
targetName: row.guideName ?? `Stygian (${row.scheduleName})`,
href: `${row.guideSlug ? `/${row.guideSlug}/comment` : `/stygian/comment?schedule=${row.scheduleId}`}#comment-${c.rootId ?? c.id}`,
};
});
const last = page.at(-1);
return { items, nextCursor: rows.length > 20 && last ? encodeCursor({ time: last.cursorTime, id: last.comment.id, likes: Number(last.likes) }) : null, viewer };
}
export async function commentHistory(id: string, viewer: CommentViewer | null, cursor: string | null = null) {
const context = await authorizeComment(id, viewer);
if (context.comment.deletedAt && !viewer?.admin) throw new HttpError(404, "comment-not-found");
const before = cursor ? Number(cursor) : context.comment.version + 1;
if (!Number.isSafeInteger(before) || before < 1) throw new HttpError(400, "invalid-cursor");
const rows = await getDb().select().from(commentRevisions).where(and(eq(commentRevisions.commentId, id), lt(commentRevisions.version, before)))
.orderBy(desc(commentRevisions.version)).limit(21);
const page = rows.slice(0, 20);
const images = await revisionImages(page.map((revision) => revision.id));
return { items: page.map((revision): CommentHistoryItem => ({ id: revision.id, version: revision.version, text: revision.text,
createdAt: revision.createdAt.toISOString(), images: images.get(revision.id) ?? [] })), nextCursor: rows.length > 20 ? String(page.at(-1)!.version) : null };
}
/** All discussion mutations lock the thread first, including hide/delete and replies. */
export async function withCommentLock<T>(id: string, viewer: CommentViewer, task: (db: Reader, context: Awaited<ReturnType<typeof authorizeComment>>) => Promise<T>) {
const initial = await authorizeComment(id, viewer);
return getDb().transaction(async (tx) => {
await tx.select({ id: commentThreads.id }).from(commentThreads).where(eq(commentThreads.id, initial.thread.id)).for("update");
const context = await authorizeComment(id, viewer, tx);
if (!context.destination.writable) throw new HttpError(409, "guide-trashed");
return task(tx, context);
});
}
export async function mutateComment(id: string, viewer: CommentViewer, action: "delete" | "reaction" | "moderation" | "heart" | "ban", value?: number | boolean) {
let target = "";
let deletedImages: { objectKey: string }[] = [];
let notificationUsers: { userId: string }[] = [];
const result = await withCommentLock(id, viewer, async (tx, context) => {
target = context.destination.target;
const c = context.comment;
if (action === "ban") {
requireCommentAdmin(viewer);
const [author] = await tx.select({ id: users.id, email: users.email, role: users.role, emailVerified: users.emailVerified })
.from(users).where(eq(users.id, c.authorId)).limit(1).for("update");
if (!author || author.id === viewer.id || Boolean(isAuthorizedAdmin(author))) throw new HttpError(403, "comment-author-ban-not-allowed");
await tx.update(users).set({ banned: Boolean(value), banReason: value ? "Banned by comment moderation" : null, banExpires: null }).where(eq(users.id, author.id));
if (value) await tx.delete(sessions).where(eq(sessions.userId, author.id));
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
await writeAuditLog(tx, auditActor(actor), { action: value ? "comment.author_banned" : "comment.author_unbanned", targetType: "comment", targetId: id,
metadata: { discussionTarget: target, authorId: author.id } });
return;
}
if (action === "moderation") {
requireCommentAdmin(viewer);
notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications)
.innerJoin(comments, eq(comments.id, notifications.commentId)).where(or(eq(comments.id, id), eq(comments.rootId, id)));
await tx.update(comments).set({ hidden: value as boolean }).where(eq(comments.id, id));
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
await writeAuditLog(tx, auditActor(actor), { action: value ? "comment.hidden" : "comment.restored", targetType: "comment", targetId: id,
metadata: { discussionTarget: context.destination.target } });
return;
}
if ((c.deletedAt || c.hidden || context.rootHidden) && !(action === "delete" && viewer.admin)) throw new HttpError(409, "comment-unavailable");
if (action === "delete") {
if (c.authorId !== viewer.id && !viewer.admin) throw new HttpError(403, "not-comment-author");
const descendants = sql`with recursive descendants(id) as (
select ${comments.id} from ${comments} where ${comments.id} = ${id} and ${comments.threadId} = ${c.threadId}
union
select child.id from ${comments} child join descendants parent on child.reply_to_id = parent.id
where child.thread_id = ${c.threadId}
) select id from descendants`;
deletedImages = await tx.select({ objectKey: commentAttachments.objectKey }).from(commentAttachments)
.where(sql`${commentAttachments.commentId} in (${descendants})`);
notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications)
.where(sql`${notifications.commentId} in (${descendants})`);
// Delete the entire subtree in one statement so self-referencing foreign keys remain valid.
// Revisions, attachment metadata, revision links, and reactions cascade automatically.
await tx.delete(comments).where(sql`${comments.id} in (${descendants})`);
if (viewer.admin) {
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
await writeAuditLog(tx, auditActor(actor), { action: "comment.deleted", targetType: "comment", targetId: id,
metadata: { discussionTarget: context.destination.target } });
}
} else if (action === "heart") {
requireCommentAdmin(viewer);
await tx.update(comments).set({ heartedById: value ? viewer.id : null }).where(eq(comments.id, id));
if (value && !c.heartedById && c.authorId !== viewer.id) {
const [author] = await tx.select({ id: users.id, email: users.email, role: users.role, emailVerified: users.emailVerified, banned: users.banned })
.from(users).where(eq(users.id, c.authorId)).limit(1);
if (!author || author.banned) return;
let destination;
try {
({ destination } = await authorizeComment(id, { id: author.id, admin: isAuthorizedAdmin(author) }, tx));
} catch (cause) {
if (cause instanceof HttpError && cause.status === 404) return;
throw cause;
}
const url = `${destination.href}${c.rootId ? `${destination.href.includes("?") ? "&" : "?"}reply=${id}` : ""}#comment-${c.rootId ?? id}`;
notificationUsers = await tx.insert(notifications).values({
userId: author.id, eventKey: `comment:${id}:heart`, kind: "comment_heart",
title: "คุณได้รับหัวใจจากแอดมิน", body: `แอดมินให้หัวใจความคิดเห็นของคุณใน ${destination.name}`,
url, commentId: id,
}).onConflictDoNothing({ target: [notifications.userId, notifications.eventKey] }).returning({ userId: notifications.userId });
}
} else if (value === 0) {
await tx.delete(commentReactions).where(and(eq(commentReactions.commentId, id), eq(commentReactions.userId, viewer.id)));
} else {
await tx.insert(commentReactions).values({ commentId: id, userId: viewer.id, value: value as number })
.onConflictDoUpdate({ target: [commentReactions.commentId, commentReactions.userId], set: { value: value as number } });
}
});
for (let offset = 0; offset < notificationUsers.length; offset += 10)
await Promise.all(notificationUsers.slice(offset, offset + 10).map(user => notifyNotificationChange(user.userId)));
await notifyCommentChange(target);
if (deletedImages.length) {
try {
const storage = await getMediaStorage();
const cleanup = await Promise.allSettled(deletedImages.map((image) => storage.delete(image.objectKey)));
if (cleanup.some((entry) => entry.status === "rejected")) console.error("Comment image cleanup failed");
} catch { console.error("Comment image cleanup failed"); }
}
return result;
}
export async function commentImage(id: string, viewer: CommentViewer | null) {
commentId(id);
const [image] = await getDb().select().from(commentAttachments).where(eq(commentAttachments.id, id)).limit(1);
if (!image) throw new HttpError(404, "image-not-found");
const context = await authorizeComment(image.commentId, viewer);
if (context.comment.deletedAt && !viewer?.admin) throw new HttpError(404, "image-not-found");
return image;
}
export async function listCommentTargets() {
const [characters, schedules] = await Promise.all([
getDb().select({ id: guides.id, name: guides.name, characterKey: guides.characterKey, imageKey: catalogCharacters.imageKey, rarity: catalogCharacters.rarity }).from(guides).leftJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey)).orderBy(asc(guides.name)),
getDb().select({ id: stygianSchedules.scheduleId, name: stygianSchedules.challengeName }).from(stygianSchedules).orderBy(desc(stygianSchedules.scheduleId)),
]);
return [...characters.map((g) => ({ value: `guide:${g.id}`, label: g.name, image: g.imageKey ? publicMediaUrl(g.imageKey) : null, rarity: g.rarity, characterKey: g.characterKey })), ...schedules.map((s) => ({ value: `stygian:${s.id}`, label: `Stygian (${s.name})`, image: null, rarity: null, characterKey: null }))];
}