diff --git a/app/api/comments/[id]/[action]/route.ts b/app/api/comments/[id]/[action]/route.ts index 3c3a4bc..6d679be 100644 --- a/app/api/comments/[id]/[action]/route.ts +++ b/app/api/comments/[id]/[action]/route.ts @@ -1,5 +1,5 @@ import { commentHistory, getCommentViewer, listComments, mutateComment, requireCommentViewer } from "@/lib/comments/repository"; -import { heartSchema, moderationSchema, reactionSchema } from "@/lib/comments/validation"; +import { commentBanSchema, heartSchema, moderationSchema, reactionSchema } from "@/lib/comments/validation"; import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; import { limitRequest } from "@/lib/security/rate-limit"; @@ -31,6 +31,10 @@ export async function PUT(request: Request, context: Context) { const value = moderationSchema.safeParse(body); if (!value.success) throw new HttpError(400, "invalid-moderation"); await mutateComment(id, viewer, "moderation", value.data.hidden); + } else if (action === "ban") { + const value = commentBanSchema.safeParse(body); + if (!value.success) throw new HttpError(400, "invalid-moderation"); + await mutateComment(id, viewer, "ban", value.data.banned); } else if (action === "heart") { const value = heartSchema.safeParse(body); if (!value.success) throw new HttpError(400, "invalid-heart"); diff --git a/components/comments/admin-comment-inbox.tsx b/components/comments/admin-comment-inbox.tsx index 11f675b..8efaa75 100644 --- a/components/comments/admin-comment-inbox.tsx +++ b/components/comments/admin-comment-inbox.tsx @@ -10,7 +10,7 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"; -import { Separator } from "@/components/ui/separator"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import type { CommentPage } from "@/lib/comments/types"; import { characterRarityGradientClass } from "@/lib/catalog/rarity"; import { cn } from "@/lib/utils"; @@ -54,22 +54,21 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm {feed.error && {feed.error}} {feed.page.items.length > 0 &&
-
- ความคิดเห็นเนื้อหา -
- - {feed.page.items.map((item) =>
-
- feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} + + + ความคิดเห็น + เนื้อหา + + + {feed.page.items.map((item) => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} overview={item.target.startsWith("stygian:") ? {item.targetName} : item.targetImage && {/* eslint-disable-next-line @next/next/no-img-element */} {`ภาพ - } /> - - - )} + } />)} + +
} {!feed.page.items.length && !feed.busy && !feed.error && ไม่พบความคิดเห็นที่ตรงกันความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่} {feed.busy && } diff --git a/components/comments/client.ts b/components/comments/client.ts index cdf7b60..9f57bad 100644 --- a/components/comments/client.ts +++ b/components/comments/client.ts @@ -15,6 +15,8 @@ export const COMMENT_ERRORS: Record = { "comment-edited-reload": "ความคิดเห็นนี้ถูกแก้ไขแล้ว กรุณาโหลดใหม่ก่อนบันทึก ข้อความร่างของคุณยังอยู่", "comment-unavailable": "ไม่สามารถทำรายการกับความคิดเห็นนี้ได้แล้ว", "comment-not-editable": "ไม่สามารถแก้ไขความคิดเห็นนี้ได้แล้ว", + "comment-author-banned": "บัญชีของคุณถูกระงับการแสดงความคิดเห็น", + "comment-author-ban-not-allowed": "ไม่สามารถแบนผู้เขียนความคิดเห็นนี้ได้", "guide-trashed": "ไกด์นี้อยู่ในถังขยะ จึงไม่สามารถแสดงความคิดเห็นได้", }; export async function commentRequest(url: string, options?: RequestInit): Promise { diff --git a/components/comments/comment-card.tsx b/components/comments/comment-card.tsx index 373fd85..ccbb0be 100644 --- a/components/comments/comment-card.tsx +++ b/components/comments/comment-card.tsx @@ -3,18 +3,20 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; -import { ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EllipsisVerticalIcon, HeartIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"; +import { BanIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EllipsisVerticalIcon, HeartIcon, PencilIcon, ShieldCheckIcon, ThumbsDownIcon, ThumbsUpIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; import { cn } from "@/lib/utils"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { CommentSkeleton, CommentThreadSkeleton } from "./comment-skeleton"; import { Separator } from "@/components/ui/separator"; +import { TableCell, TableRow } from "@/components/ui/table"; import type { CommentHistoryItem, CommentImage, CommentItem, CommentViewer } from "@/lib/comments/types"; import { CommentImageView } from "./comment-image"; import { CommentComposer } from "./comment-composer"; @@ -131,6 +133,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals const replyToggleRef = useRef(null); const [history, setHistory] = useState(false); const [deleting, setDeleting] = useState(false); + const [banning, setBanning] = useState(false); const [pending, setPending] = useState(false); const [reactionOverride, setReactionOverride] = useState<{ reaction: number; likes: number; hearted: boolean } | null>(null); const optimistic = reactionOverride ?? { reaction: item.reaction, likes: item.likes, hearted: item.hearted }; @@ -148,7 +151,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals if (reactionOverride) void Promise.resolve().then(() => setReactionOverride((current) => current === reactionOverride ? null : current)); } }, [item.reaction, item.likes, item.hearted, reactionOverride]); - async function mutate(action: "reaction" | "heart" | "moderation" | "delete", value?: number | boolean) { + async function mutate(action: "reaction" | "heart" | "ban" | "delete", value?: number | boolean) { if (!viewer) return router.push(commentSignInHref()); if (action === "reaction" || action === "heart") { const sequence = ++reactionSequence.current; @@ -193,9 +196,9 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals try { await commentRequest(`/api/comments/${item.id}${action === "delete" ? "" : `/${action}`}`, { method: action === "delete" ? "DELETE" : "PUT", - ...(action === "delete" ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hidden: value }) }), + ...(action === "delete" ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ banned: value }) }), }); - setDeleting(false); onChange(); + setDeleting(false); setBanning(false); await onChange(); } catch (cause) { toast.error(cause instanceof Error ? cause.message : "ไม่สามารถอัปเดตความคิดเห็นได้"); } finally { setPending(false); } } @@ -207,12 +210,18 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals const optionsMenu = (item.canEdit || viewer?.admin) && }> - {item.canEdit && <> compose("edit")}>แก้ไข setDeleting(true)}>ลบ} - {viewer?.admin && void mutate("moderation", !item.ownHidden)}>{item.ownHidden ? "แสดงอีกครั้ง" : "ซ่อน"}} + {item.canEdit && compose("edit")}>แก้ไข} + {(item.canEdit || viewer?.admin) && setDeleting(true)}>ลบ} + {viewer?.admin && item.authorBanned ? void mutate("ban", false) : setBanning(true)}> + {item.authorBanned ? : }{item.authorBanned ? "ยกเลิกแบนผู้เขียน" : "แบนผู้เขียน"} + } ; - return
+ const Container = inbox ? TableRow : "article"; + const Content = inbox ? TableCell : "div"; + return +
{item.authorName.slice(0, 2)}
@@ -255,14 +264,21 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
- {inbox &&
{optionsMenu}{overview}
} +
+ {inbox && +
{optionsMenu}{overview}
+
} ประวัติการแก้ไขดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น {history && } ลบความคิดเห็นนี้?ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้
+ { if (!pending) setBanning(open); }}> + แบน {item.authorName}?ผู้เขียนจะถูกออกจากระบบและไม่สามารถเข้าสู่ระบบ แสดงความคิดเห็น หรือใช้งานบัญชีได้ จนกว่าผู้ดูแลจะยกเลิกแบน ความคิดเห็นเดิมจะยังอยู่ + ยกเลิก void mutate("ban", true)}>แบนผู้เขียน + -
; + ; } function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise; moderationDetails?: boolean }) { diff --git a/components/comments/comment-skeleton.tsx b/components/comments/comment-skeleton.tsx index 79a8fa7..e8af740 100644 --- a/components/comments/comment-skeleton.tsx +++ b/components/comments/comment-skeleton.tsx @@ -1,23 +1,26 @@ import { Skeleton } from "@/components/ui/skeleton"; -import { Separator } from "@/components/ui/separator"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; export function AdminCommentRowsSkeleton({ count = 3 }: { count?: number }) { return
กำลังโหลดความคิดเห็น… - + + {Array.from({ length: count }, (_, index) => + +
+
+ +
+
+
+ +
+ + +
+
+
)}
+
; } diff --git a/lib/audit-log.ts b/lib/audit-log.ts index d542be3..d47aa37 100644 --- a/lib/audit-log.ts +++ b/lib/audit-log.ts @@ -9,6 +9,9 @@ import { securityLog } from "@/lib/security/http"; export const AUDIT_ACTIONS = [ "comment.hidden", "comment.restored", + "comment.deleted", + "comment.author_banned", + "comment.author_unbanned", "guide.created", "guide.overview.saved", "guide.weapon.saved", @@ -189,6 +192,9 @@ export const AUDIT_TARGET_LABELS: Record< export const AUDIT_ACTION_LABELS: Record = { "comment.hidden": "ซ่อน Comment", "comment.restored": "กู้คืน Comment", + "comment.deleted": "ลบ Comment ถาวร", + "comment.author_banned": "แบนผู้เขียน Comment", + "comment.author_unbanned": "ยกเลิกแบนผู้เขียน Comment", "guide.created": "สร้าง Guide", "guide.overview.saved": "บันทึก Overview", "guide.weapon.saved": "บันทึก Weapons", diff --git a/lib/comments/admin-moderation.test.ts b/lib/comments/admin-moderation.test.ts new file mode 100644 index 0000000..3c01f8a --- /dev/null +++ b/lib/comments/admin-moderation.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + comment: {} as Record, author: {} as Record, + rootHidden: false, trashed: false, deletes: vi.fn(), updates: vi.fn(), audit: vi.fn(), changed: vi.fn(), +})); +vi.mock("server-only", () => ({})); +vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() })); +vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: state.changed })); +vi.mock("@/lib/auth/server", () => ({ getCustomerSession: vi.fn() })); +vi.mock("@/lib/audit-log", () => ({ auditActor: (actor: unknown) => actor, writeAuditLog: state.audit })); +vi.mock("@/lib/media/storage", () => ({ getMediaStorage: vi.fn(), publicMediaUrl: vi.fn() })); +vi.mock("@/db", () => ({ getDb: () => database })); + +import { comments, guides, sessions, users } from "@/db/schema"; +import { mutateComment } from "./repository"; +import { publishComment } from "./publish"; + +const id = "72df08ab-50dd-4cbd-9a69-70949d34cf9f"; +const guideId = "72df08ab-50dd-4cbd-9a69-70949d34cf9e"; +const admin = { id: "admin", admin: true }; +const reader = { id: "reader", admin: false }; +function select(fields: Record) { + let table: unknown; + const rows = () => { + if (table === comments) return fields.comment + ? [{ comment: state.comment, thread: { id: "thread", guideId } }] + : [{ hidden: state.rootHidden }]; + if (table === guides) return [{ id: guideId, name: "Amber", slug: "amber", public: true, trashedAt: state.trashed ? new Date() : null }]; + if (table === users) return fields.role || fields.banned ? [state.author] : [{ id: admin.id, name: "Admin" }]; + return []; + }; + const query = { + from(value: unknown) { table = value; return query; }, + innerJoin() { return query; }, where() { return query; }, limit() { return query; }, for() { return query; }, + then(resolve: (value: unknown[]) => unknown) { return Promise.resolve(rows()).then(resolve); }, + }; + return query; +} +const database = { + transaction: async (task: (tx: unknown) => unknown) => task(database), select, + selectDistinct: select, + update: (table: unknown) => ({ set: (value: Record) => ({ where: async () => { + state.updates(table, value); + if (table === users) Object.assign(state.author, value); + } }) }), + delete: (table: unknown) => ({ where: async () => { state.deletes(table); } }), +}; +beforeEach(() => { + state.comment = { id, authorId: reader.id, threadId: "thread", rootId: null, hidden: false, deletedAt: null }; + state.author = { id: reader.id, email: "reader@example.test", role: "user", emailVerified: true, banned: false }; + state.rootHidden = false; state.trashed = false; + state.deletes.mockReset(); state.updates.mockReset(); state.audit.mockReset(); state.changed.mockReset(); +}); + +describe("admin comment deletion", () => { + it.each(["visible", "hidden", "hidden root", "deleted placeholder"])("allows an admin to delete another author's %s comment and records it", async (visibility) => { + state.comment.hidden = visibility === "hidden"; + state.comment.deletedAt = visibility === "deleted placeholder" ? new Date() : null; + if (visibility === "hidden root") { state.comment.rootId = id; state.rootHidden = true; } + await mutateComment(id, admin, "delete"); + expect(state.deletes).toHaveBeenCalledWith(comments); + expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.deleted", targetId: id })); + expect(state.changed).toHaveBeenCalledWith(`guide:${guideId}`); + }); + it("rejects deletion by another regular user", async () => { + await expect(mutateComment(id, { id: "other", admin: false }, "delete")).rejects.toMatchObject({ status: 403 }); + expect(state.deletes).not.toHaveBeenCalled(); + }); + it("retains author deletion", async () => { + await mutateComment(id, reader, "delete"); + expect(state.deletes).toHaveBeenCalledWith(comments); + }); + it("rejects deletion for a trashed guide", async () => { + state.trashed = true; + await expect(mutateComment(id, admin, "delete")).rejects.toMatchObject({ status: 409 }); + expect(state.deletes).not.toHaveBeenCalled(); + }); +}); +describe("account bans from comment moderation", () => { + it("sets Better Auth's ban fields, revokes sessions, and audits without deleting comments", async () => { + await mutateComment(id, admin, "ban", true); + expect(state.updates).toHaveBeenCalledWith(users, { banned: true, banReason: "Banned by comment moderation", banExpires: null }); + expect(state.deletes).toHaveBeenCalledWith(sessions); + expect(state.deletes).not.toHaveBeenCalledWith(comments); + expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.author_banned" })); + }); + it("clears the ban and audits without revoking sessions", async () => { + state.author.banned = true; + await mutateComment(id, admin, "ban", false); + expect(state.author).toMatchObject({ banned: false, banReason: null, banExpires: null }); + expect(state.deletes).not.toHaveBeenCalled(); + expect(state.audit).toHaveBeenCalledWith(database, expect.anything(), expect.objectContaining({ action: "comment.author_unbanned" })); + }); + it.each(["regular user", "self", "another admin"])("rejects bans for %s", async (scenario) => { + const actor = scenario === "regular user" ? reader : admin; + if (scenario === "self") { state.author.id = admin.id; state.comment.authorId = admin.id; } + if (scenario === "another admin") state.author.role = "admin"; + await expect(mutateComment(id, actor, "ban", true)).rejects.toMatchObject({ status: 403 }); + expect(state.updates).not.toHaveBeenCalled(); expect(state.deletes).not.toHaveBeenCalled(); + }); + it.each(["new comment", "reply", "edit"])("rejects a banned author's %s before processing uploads", async (kind) => { + state.author.banned = true; + const body = new FormData(); body.set("text", "New comment"); + if (kind === "reply") body.set("replyToId", id); + await expect(publishComment(new Request("https://guide.example.test/api/comments", { method: "POST", body }), reader, + kind === "edit" ? { id } : { target: `guide:${guideId}` })).rejects.toMatchObject({ status: 403, message: "comment-author-banned" }); + expect(state.updates).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/comments/publish.ts b/lib/comments/publish.ts index 66a7411..ff83c00 100644 --- a/lib/comments/publish.ts +++ b/lib/comments/publish.ts @@ -4,7 +4,7 @@ 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 { commentAttachments, commentRevisions, commentRevisionAttachments, comments, commentThreads, users } from "@/db/schema"; import { inspectImage } from "@/lib/media/inspect"; import { getMediaStorage } from "@/lib/media/storage"; import { boundedBody, HttpError, withUploadSlot } from "@/lib/security/http"; @@ -17,6 +17,13 @@ type ParsedForm = ReturnType; type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number }; type Writer = Parameters["transaction"]>[0]>[0]; +async function requireCommentPosting(db: Pick, userId: string, lock = false) { + const query = db.select({ banned: users.banned }).from(users).where(eq(users.id, userId)).limit(1); + const [user] = await (lock ? query.for("update") : query); + if (!user) throw new HttpError(401, "sign-in-required"); + if (user.banned) throw new HttpError(403, "comment-author-banned"); +} + async function uploadCommentImage(storage: Awaited>, image: Upload, bytes: Uint8Array) { for (let attempt = 0; ; attempt++) { try { @@ -49,6 +56,7 @@ async function saveRevision(tx: Pick, id: string, v 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"); + await requireCommentPosting(getDb(), viewer.id); 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"); @@ -69,6 +77,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op } if (options.id) { const version = await withCommentLock(options.id, viewer, async (tx, context) => { + await requireCommentPosting(tx, viewer.id, true); 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"); @@ -82,6 +91,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op 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"); + await requireCommentPosting(tx, viewer.id, true); const target = await getCommentTarget(options.target!, viewer, tx); if (!target.writable) throw new HttpError(409, "guide-trashed"); let rootId: string | null = null; diff --git a/lib/comments/repository.ts b/lib/comments/repository.ts index b388df7..c11a45f 100644 --- a/lib/comments/repository.ts +++ b/lib/comments/repository.ts @@ -5,7 +5,7 @@ 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, notifications } from "@/db/schema"; +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"; @@ -144,6 +144,7 @@ export async function listComments(options: { cursorTime: sql`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`coalesce(${users.role} = 'admin' and ${users.emailVerified}, false)`, + authorBanned: users.banned, replyToName: recipient.name, rootHidden: root.hidden, likes, replyCount, hasReplies: sql`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`coalesce((select value from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.userId} = ${viewer.id}), 0)` : sql`0`, @@ -165,6 +166,8 @@ export async function listComments(options: { 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, @@ -205,13 +208,25 @@ export async function withCommentLock(id: string, viewer: CommentViewer, task }); } -export async function mutateComment(id: string, viewer: CommentViewer, action: "delete" | "reaction" | "moderation" | "heart", value?: number | boolean) { +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) @@ -222,9 +237,9 @@ export async function mutateComment(id: string, viewer: CommentViewer, action: " metadata: { discussionTarget: context.destination.target } }); return; } - if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable"); + 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) throw new HttpError(403, "not-comment-author"); + 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 @@ -238,6 +253,11 @@ export async function mutateComment(id: string, viewer: CommentViewer, action: " // 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)); diff --git a/lib/comments/types.ts b/lib/comments/types.ts index fd01208..628131f 100644 --- a/lib/comments/types.ts +++ b/lib/comments/types.ts @@ -8,6 +8,8 @@ export interface CommentItem { authorName: string; authorImage: string | null; authorAdmin: boolean; + authorBanned?: boolean; + canBanAuthor?: boolean; text: string; images: CommentImage[]; version: number; diff --git a/lib/comments/validation.ts b/lib/comments/validation.ts index 007d81c..66823b8 100644 --- a/lib/comments/validation.ts +++ b/lib/comments/validation.ts @@ -9,6 +9,7 @@ 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 commentBanSchema = z.strictObject({ banned: 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}$/),