feat : comment feature
CI / Verify (push) Successful in 2m30s
CI / Build immutable images and deploy (push) Successful in 3m9s

This commit is contained in:
2026-10-08 01:40:08 +07:00 Unverified
parent 8c8815cda5
commit 5812d99715
65 changed files with 27013 additions and 10 deletions
+17
View File
@@ -0,0 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const redis = vi.hoisted(() => ({ publish: vi.fn(), get: vi.fn() }));
vi.mock("@/lib/redis/client", () => ({ getRedisClient: redis.get, redisEventChannel: (topic: string) => `test:${topic}` }));
import { notifyCommentChange } from "./events";
describe("comment invalidation events", () => {
beforeEach(() => { redis.publish.mockReset().mockResolvedValue(1); redis.get.mockReset().mockResolvedValue(redis); });
it("notifies the target and admin inbox without exposing comment content", async () => {
await notifyCommentChange("stygian:123");
expect(redis.publish.mock.calls).toEqual([["test:comments:stygian:123", "changed"], ["test:comments:admin", "changed"]]);
});
it("does not fail a committed mutation when Redis is unavailable", async () => {
redis.get.mockRejectedValue(new Error("offline"));
await expect(notifyCommentChange("stygian:123")).resolves.toBeUndefined();
});
});
+15
View File
@@ -0,0 +1,15 @@
import "server-only";
import { getRedisClient, redisEventChannel } from "@/lib/redis/client";
export async function notifyCommentChange(target: string) {
try {
const redis = await getRedisClient();
await Promise.all([
redis.publish(redisEventChannel(`comments:${target}`), "changed"),
redis.publish(redisEventChannel("comments:admin"), "changed"),
]);
} catch {
// A committed comment remains saved when Pub/Sub is unavailable.
}
}
+3
View File
@@ -0,0 +1,3 @@
export function commentNavigationSection(guideId: string) {
return { id: `comment-${guideId}`, guideId, kind: "comment", slug: "comment", title: "Comment", note: null, enabled: true, sortOrder: 2147483647 };
}
+87
View File
@@ -0,0 +1,87 @@
import "server-only";
import { notifyCommentChange } from "./events";
import { and, eq, inArray } from "drizzle-orm";
import { getDb } from "@/db";
import { commentAttachments, commentRevisions, commentRevisionAttachments, comments, commentThreads } from "@/db/schema";
import { inspectImage } from "@/lib/media/inspect";
import { getMediaStorage } from "@/lib/media/storage";
import { boundedBody, HttpError, withUploadSlot } from "@/lib/security/http";
import { authorizeComment, commentId, ensureCommentThread, getCommentTarget, withCommentLock } from "./repository";
import { MAX_COMMENT_BODY_BYTES, parseCommentForm } from "./validation";
import type { CommentViewer } from "./types";
type ParsedForm = ReturnType<typeof parseCommentForm>;
type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number };
type Writer = Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0];
async function saveRevision(tx: Pick<Writer, "insert" | "select">, id: string, version: number, form: ParsedForm, uploaded: Upload[]) {
if (form.keep.length) {
const own = await tx.select({ id: commentAttachments.id }).from(commentAttachments)
.where(and(eq(commentAttachments.commentId, id), inArray(commentAttachments.id, form.keep)));
if (own.length !== form.keep.length) throw new HttpError(400, "invalid-retained-image");
}
if (uploaded.length) await tx.insert(commentAttachments).values(uploaded.map((image) => ({ ...image, commentId: id })));
const [revision] = await tx.insert(commentRevisions).values({ commentId: id, version, text: form.text }).returning({ id: commentRevisions.id });
const images = [...form.keep, ...uploaded.map((image) => image.id)];
if (images.length) await tx.insert(commentRevisionAttachments).values(images.map((attachmentId, position) => ({ revisionId: revision.id, attachmentId, position })));
}
export async function publishComment(request: Request, viewer: CommentViewer, options: { target?: string; id?: string }) {
if (!request.headers.get("content-type")?.startsWith("multipart/form-data;")) throw new HttpError(415, "expected-multipart");
const initial = options.id ? await authorizeComment(commentId(options.id), viewer) : null;
if (initial && (initial.comment.authorId !== viewer.id || initial.comment.deletedAt || initial.comment.hidden || initial.rootHidden))
throw new HttpError(403, "comment-not-editable");
const destination = initial?.destination ?? await getCommentTarget(options.target!, viewer);
const result = await withUploadSlot(async () => {
const form = parseCommentForm(await boundedBody(request, MAX_COMMENT_BODY_BYTES).formData(), Boolean(options.id));
const uploaded: Upload[] = [];
try {
const storage = form.files.length ? await getMediaStorage() : null;
for (const file of form.files) {
const bytes = new Uint8Array(await file.arrayBuffer());
await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp");
const image = { id: crypto.randomUUID(), objectKey: `comments/${crypto.randomUUID()}`, mimeType: file.type, byteSize: file.size };
uploaded.push(image);
// Browser images load directly from the configured S3 public CDN.
await storage!.write(image.objectKey, bytes, { type: file.type, acl: "public-read" });
}
if (options.id) {
await withCommentLock(options.id, viewer, async (tx, context) => {
const c = context.comment;
if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author");
if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable");
if (c.version !== form.version) throw new HttpError(409, "comment-edited-reload");
await saveRevision(tx, c.id, c.version + 1, form, uploaded);
await tx.update(comments).set({ version: c.version + 1 }).where(eq(comments.id, c.id));
});
return { id: options.id };
}
const thread = await ensureCommentThread(options.target!, viewer);
return await getDb().transaction(async (tx) => {
await tx.select({ id: commentThreads.id }).from(commentThreads).where(eq(commentThreads.id, thread.id)).for("update");
const target = await getCommentTarget(options.target!, viewer, tx);
if (!target.writable) throw new HttpError(409, "guide-trashed");
let rootId: string | null = null;
if (form.replyToId) {
const parent = await authorizeComment(form.replyToId, viewer, tx);
if (parent.comment.threadId !== thread.id) throw new HttpError(400, "cross-thread-reply");
if (parent.comment.deletedAt || parent.comment.hidden || parent.rootHidden) throw new HttpError(409, "comment-unavailable");
rootId = parent.comment.rootId ?? parent.comment.id;
}
const [comment] = await tx.insert(comments).values({ threadId: thread.id, authorId: viewer.id, rootId, replyToId: form.replyToId }).returning({ id: comments.id });
await saveRevision(tx, comment.id, 1, form, uploaded);
return { id: comment.id };
});
} catch (cause) {
if (uploaded.length) {
const storage = await getMediaStorage();
for (const image of uploaded) await storage.delete(image.objectKey).catch(() => undefined);
}
throw cause;
}
});
await notifyCommentChange(destination.target);
return result;
}
+42
View File
@@ -0,0 +1,42 @@
import "server-only";
import { and, eq } from "drizzle-orm";
import webPush from "web-push";
import { getDb } from "@/db";
import { comments, commentPushSubscriptions, commentRevisions, users } from "@/db/schema";
import { pushPublicKey, validPushEndpoint } from "@/lib/commission/push";
import { authorizeComment } from "./repository";
export async function sendCommentReplyPush(id: string) {
if (!pushPublicKey()) return;
const [reply] = await getDb().select().from(comments).where(eq(comments.id, id)).limit(1);
if (!reply?.replyToId || reply.hidden || reply.deletedAt) return;
const [parent] = await getDb().select().from(comments).where(eq(comments.id, reply.replyToId)).limit(1);
if (!parent || parent.authorId === reply.authorId || parent.hidden || parent.deletedAt) return;
const [recipient] = await getDb().select().from(users).where(eq(users.id, parent.authorId)).limit(1);
if (!recipient || recipient.banned) return;
let destination;
try {
({ destination } = await authorizeComment(id, { id: recipient.id, admin: recipient.role === "admin" && recipient.emailVerified }));
} catch { return; }
const [author] = await getDb().select({ name: users.name }).from(users).where(eq(users.id, reply.authorId)).limit(1);
const [revision] = await getDb().select({ text: commentRevisions.text }).from(commentRevisions)
.where(and(eq(commentRevisions.commentId, id), eq(commentRevisions.version, reply.version))).limit(1);
const subscriptions = await getDb().select().from(commentPushSubscriptions).where(eq(commentPushSubscriptions.userId, recipient.id));
if (!subscriptions.length) return;
webPush.setVapidDetails(process.env.WEB_PUSH_SUBJECT!, process.env.WEB_PUSH_PUBLIC_KEY!, process.env.WEB_PUSH_PRIVATE_KEY!);
const payload = JSON.stringify({ id: `comment-${id}`, title: `${author?.name ?? "ผู้ใช้"} ตอบกลับความคิดเห็นของคุณ`,
body: revision?.text.slice(0, 300) || "ส่งรูปภาพ", icon: "/icon/nav/Comment.webp",
url: `${destination.href}${destination.href.includes("?") ? "&" : "?"}reply=${id}#comment-${reply.rootId}` });
for (let offset = 0; offset < subscriptions.length; offset += 10) {
await Promise.all(subscriptions.slice(offset, offset + 10).map(async (subscription) => {
if (!validPushEndpoint(subscription.endpoint)) return;
try {
await webPush.sendNotification({ endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth } }, payload, { TTL: 60 * 60 * 24, timeout: 5000 });
} catch (cause) {
if (cause && typeof cause === "object" && "statusCode" in cause && (cause.statusCode === 404 || cause.statusCode === 410))
await getDb().delete(commentPushSubscriptions).where(eq(commentPushSubscriptions.endpoint, subscription.endpoint));
}
}));
}
}
+263
View File
@@ -0,0 +1,263 @@
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 } from "@/db/schema";
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" | "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; status?: string; unanswered?: boolean;
}): Promise<CommentPage> {
const { viewer } = options;
if (options.inbox) requireCommentAdmin(viewer);
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 (!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 (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)`,
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,
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", value?: number | boolean) {
let target = "";
let deletedImages: { objectKey: string }[] = [];
const result = await withCommentLock(id, viewer, async (tx, context) => {
target = context.destination.target;
const c = context.comment;
if (action === "moderation") {
requireCommentAdmin(viewer);
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) throw new HttpError(409, "comment-unavailable");
if (action === "delete") {
if (c.authorId !== viewer.id) 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})`);
// 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})`);
} else if (action === "heart") {
requireCommentAdmin(viewer);
await tx.update(comments).set({ heartedById: value ? viewer.id : null }).where(eq(comments.id, id));
} 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 } });
}
});
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 }))];
}
+33
View File
@@ -0,0 +1,33 @@
export interface CommentViewer { id: string; admin: boolean; name?: string; image?: string | null }
export interface CommentImage { id: string; url: string }
export interface CommentItem {
id: string;
rootId: string | null;
replyToId: string | null;
replyToName: string | null;
authorName: string;
authorImage: string | null;
authorAdmin: boolean;
text: string;
images: CommentImage[];
version: number;
createdAt: string;
editedAt: string;
hidden: boolean;
ownHidden: boolean;
deleted: boolean;
hearted: boolean;
likes: number;
reaction: number;
replyCount: number;
hasReplies: boolean;
canEdit: boolean;
canInteract: boolean;
canModerate: boolean;
target: string;
targetName: string;
targetImage: string | null;
href: string;
}
export interface CommentPage { items: CommentItem[]; nextCursor: string | null; viewer: CommentViewer | null }
export interface CommentHistoryItem { id: string; version: number; text: string; createdAt: string; images: CommentImage[] }
+30
View File
@@ -0,0 +1,30 @@
import "server-only";
import { and, eq, isNull, ne, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { getDb } from "@/db";
import { comments, commentThreads, commentReadState, guides } from "@/db/schema";
import { getCommentTarget, requireCommentAdmin } from "./repository";
import type { CommentViewer } from "./types";
export async function unreadCommentCounts(viewer: CommentViewer) {
requireCommentAdmin(viewer);
const global = alias(commentReadState, "global_read");
const local = alias(commentReadState, "target_read");
const target = sql<string>`case when ${commentThreads.guideId} is not null then 'guide:' || ${commentThreads.guideId}::text else 'stygian:' || ${commentThreads.scheduleId}::text end`;
const rows = await getDb().select({ target, count: sql<number>`count(*)::int` }).from(comments)
.innerJoin(commentThreads, eq(comments.threadId, commentThreads.id))
.leftJoin(guides, eq(guides.id, commentThreads.guideId))
.leftJoin(global, and(eq(global.userId, viewer.id), eq(global.target, "all")))
.leftJoin(local, and(eq(local.userId, viewer.id), eq(local.target, target)))
.where(and(isNull(comments.deletedAt), isNull(guides.trashedAt), ne(comments.authorId, viewer.id),
sql`${comments.createdAt} > greatest(coalesce(${global.seenAt}, 'epoch'::timestamptz), coalesce(${local.seenAt}, 'epoch'::timestamptz))`))
.groupBy(target);
return { counts: Object.fromEntries(rows.map((row) => [row.target, row.count])), total: rows.reduce((sum, row) => sum + row.count, 0) };
}
export async function markCommentsRead(viewer: CommentViewer, target: string) {
requireCommentAdmin(viewer);
if (target !== "all") await getCommentTarget(target, viewer);
await getDb().insert(commentReadState).values({ userId: viewer.id, target })
.onConflictDoUpdate({ target: [commentReadState.userId, commentReadState.target], set: { seenAt: sql`now()` } });
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { decodeCursor, encodeCursor, MAX_COMMENT_IMAGE_BYTES, parseCommentForm, parseTarget } from "./validation";
const id = "11111111-1111-4111-8111-111111111111";
function form(text = "hello", count = 0, bytes = 1, type = "image/png") {
const value = new FormData();
value.set("text", text);
for (let i = 0; i < count; i++) value.append("image", new File([new Uint8Array(bytes)], `${i}.png`, { type }));
return value;
}
describe("comment form limits", () => {
it("accepts text and image-only comments", () => {
expect(parseCommentForm(form(" hello ")).text).toBe("hello");
expect(parseCommentForm(form("", 5)).files).toHaveLength(5);
expect(() => parseCommentForm(form(""))).toThrow("empty-comment");
});
it("enforces the combined retained and new image limit", () => {
expect(() => parseCommentForm(form("", 6))).toThrow("invalid-comment");
const value = form("editing", 5);
value.set("version", "1"); value.append("keepImageId", id);
expect(() => parseCommentForm(value, true)).toThrow("invalid-comment");
});
it("accepts exactly ten MiB and rejects one additional byte", () => {
expect(parseCommentForm(form("", 1, MAX_COMMENT_IMAGE_BYTES)).files).toHaveLength(1);
expect(() => parseCommentForm(form("", 1, MAX_COMMENT_IMAGE_BYTES + 1))).toThrow("image-too-large");
});
it.each(["video/mp4", "image/svg+xml", "image/gif", "application/octet-stream"])("rejects %s", (type) => {
expect(() => parseCommentForm(form("", 1, 1, type))).toThrow("unsupported-image-type");
});
it("enforces text, version, reply, and retained-image validation", () => {
expect(() => parseCommentForm(form("x".repeat(4001)))).toThrow("invalid-comment");
const value = form(); value.set("version", "0");
expect(() => parseCommentForm(value, true)).toThrow("invalid-comment");
value.set("version", "1"); value.set("replyToId", "bad");
expect(() => parseCommentForm(value, true)).toThrow("invalid-comment");
value.delete("replyToId"); value.append("keepImageId", id); value.append("keepImageId", id);
expect(() => parseCommentForm(value, true)).toThrow("invalid-comment");
});
});
describe("discussion identities and cursors", () => {
it("validates guide UUIDs and schedule IDs", () => {
expect(parseTarget(`guide:${id}`)).toEqual({ kind: "guide", id });
expect(parseTarget("stygian:5269001")).toEqual({ kind: "stygian", id: 5269001 });
for (const value of ["guide:invalid", "stygian:0", "stygian:NaN", "stygian:-1", "stygian:9999999999"]) expect(() => parseTarget(value)).toThrow("invalid-target");
});
it("round-trips keyset cursors and rejects arbitrary input", () => {
const cursor = { time: "2026-10-07T00:00:00.000Z", id, likes: 3 };
expect(decodeCursor(encodeCursor(cursor))).toEqual(cursor);
expect(() => decodeCursor("invalid")).toThrow("invalid-cursor");
expect(() => decodeCursor(encodeCursor({ ...cursor, likes: -1 }))).toThrow("invalid-cursor");
});
});
+58
View File
@@ -0,0 +1,58 @@
import * as z from "zod";
import { HttpError } from "@/lib/security/http";
export const MAX_COMMENT_IMAGES = 5;
export const MAX_COMMENT_IMAGE_BYTES = 10 * 1024 * 1024;
export const MAX_COMMENT_BODY_BYTES = MAX_COMMENT_IMAGES * MAX_COMMENT_IMAGE_BYTES + 64 * 1024;
export const COMMENT_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
export const uuidSchema = z.uuid();
export const reactionSchema = z.strictObject({ value: z.union([z.literal(-1), z.literal(0), z.literal(1)]) });
export const moderationSchema = z.strictObject({ hidden: z.boolean() });
export const heartSchema = z.strictObject({ hearted: z.boolean() });
export const targetSchema = z.union([
z.string().regex(/^guide:[0-9a-fA-F-]{36}$/).refine((value) => uuidSchema.safeParse(value.slice(6)).success),
z.string().regex(/^stygian:[1-9]\d{0,8}$/),
]);
export function parseTarget(value: unknown) {
const result = targetSchema.safeParse(value);
if (!result.success) throw new HttpError(400, "invalid-target");
return result.data.startsWith("guide:")
? { kind: "guide" as const, id: result.data.slice(6) }
: { kind: "stygian" as const, id: Number(result.data.slice(8)) };
}
export function parseCommentForm(form: FormData, editing = false) {
const rawText = form.get("text");
const text = typeof rawText === "string" ? rawText.trim() : "";
const files = form.getAll("image");
const keep = form.getAll("keepImageId");
const rawReply = form.get("replyToId");
const replyToId = rawReply === null || rawReply === "" ? null : rawReply;
const version = Number(form.get("version"));
if (text.length > 4000 || files.length + keep.length > MAX_COMMENT_IMAGES ||
keep.some((id) => !uuidSchema.safeParse(id).success) || new Set(keep).size !== keep.length ||
(!editing && keep.length) || (editing && (!Number.isSafeInteger(version) || version < 1)) ||
(replyToId !== null && !uuidSchema.safeParse(replyToId).success))
throw new HttpError(400, "invalid-comment");
if (!text && !files.length && !keep.length) throw new HttpError(400, "empty-comment");
for (const file of files) {
if (!(file instanceof File) || !COMMENT_IMAGE_TYPES.includes(file.type as typeof COMMENT_IMAGE_TYPES[number]))
throw new HttpError(415, "unsupported-image-type");
if (!file.size || file.size > MAX_COMMENT_IMAGE_BYTES) throw new HttpError(413, "image-too-large");
}
return { text, files: files as File[], keep: keep as string[], replyToId: replyToId as string | null, version };
}
const cursorSchema = z.strictObject({ time: z.iso.datetime(), id: z.uuid(), likes: z.number().int().nonnegative().max(2147483647) });
export type CommentCursor = z.infer<typeof cursorSchema>;
export function decodeCursor(value: string | null): CommentCursor | null {
if (!value) return null;
try {
if (value.length > 512) throw new Error();
return cursorSchema.parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
} catch { throw new HttpError(400, "invalid-cursor"); }
}
export function encodeCursor(value: CommentCursor) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}