feat : imrpove admin comment
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
</FieldGroup>
|
||||
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
|
||||
{feed.page.items.length > 0 && <section aria-label="ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian" className="flex min-w-0 flex-col">
|
||||
<div className="hidden grid-cols-[minmax(0,1fr)_280px] gap-6 pb-3 text-sm text-muted-foreground md:grid">
|
||||
<span>ความคิดเห็น</span><span className="text-right">เนื้อหา</span>
|
||||
</div>
|
||||
<Separator />
|
||||
{feed.page.items.map((item) => <div key={item.id}>
|
||||
<div className="py-5">
|
||||
<CommentCard item={item} viewer={feed.page.viewer} inbox onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision}
|
||||
<Table className="table-fixed max-md:block" aria-label="ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian">
|
||||
<TableHeader className="max-md:sr-only"><TableRow>
|
||||
<TableHead scope="col" className="px-0">ความคิดเห็น</TableHead>
|
||||
<TableHead scope="col" className="w-[280px] px-0 text-right">เนื้อหา</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody className="max-md:block">
|
||||
{feed.page.items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision}
|
||||
overview={item.target.startsWith("stygian:") ? <Link href={item.href} prefetch={false} className="min-w-0 break-words rounded-lg py-2 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{item.targetName}
|
||||
</Link> : item.targetImage && <Link href={item.href} prefetch={false} className="shrink-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.targetImage} alt={`ภาพ Overview ของ ${item.targetName}`} loading="lazy" decoding="async" width={112} height={112} className="size-28 rounded-lg object-contain" />
|
||||
</Link>} />
|
||||
</div>
|
||||
<Separator />
|
||||
</div>)}
|
||||
</Link>} />)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>}
|
||||
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>ไม่พบความคิดเห็นที่ตรงกัน</EmptyTitle><EmptyDescription>ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่</EmptyDescription></EmptyHeader></Empty>}
|
||||
{feed.busy && <AdminCommentRowsSkeleton count={feed.page.items.length ? 1 : 3} />}
|
||||
|
||||
@@ -15,6 +15,8 @@ export const COMMENT_ERRORS: Record<string, string> = {
|
||||
"comment-edited-reload": "ความคิดเห็นนี้ถูกแก้ไขแล้ว กรุณาโหลดใหม่ก่อนบันทึก ข้อความร่างของคุณยังอยู่",
|
||||
"comment-unavailable": "ไม่สามารถทำรายการกับความคิดเห็นนี้ได้แล้ว",
|
||||
"comment-not-editable": "ไม่สามารถแก้ไขความคิดเห็นนี้ได้แล้ว",
|
||||
"comment-author-banned": "บัญชีของคุณถูกระงับการแสดงความคิดเห็น",
|
||||
"comment-author-ban-not-allowed": "ไม่สามารถแบนผู้เขียนความคิดเห็นนี้ได้",
|
||||
"guide-trashed": "ไกด์นี้อยู่ในถังขยะ จึงไม่สามารถแสดงความคิดเห็นได้",
|
||||
};
|
||||
export async function commentRequest<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
|
||||
@@ -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<HTMLButtonElement>(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) && <DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" className="shrink-0 rounded-full" aria-label="ตัวเลือกความคิดเห็น" disabled={pending} />}><EllipsisVerticalIcon /></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end"><DropdownMenuGroup>
|
||||
{item.canEdit && <><DropdownMenuItem onClick={() => compose("edit")}>แก้ไข</DropdownMenuItem><DropdownMenuItem variant="destructive" onClick={() => setDeleting(true)}>ลบ</DropdownMenuItem></>}
|
||||
{viewer?.admin && <DropdownMenuItem disabled={!item.canModerate || (item.hidden && !item.ownHidden)} onClick={() => void mutate("moderation", !item.ownHidden)}>{item.ownHidden ? "แสดงอีกครั้ง" : "ซ่อน"}</DropdownMenuItem>}
|
||||
{item.canEdit && <DropdownMenuItem onClick={() => compose("edit")}><PencilIcon />แก้ไข</DropdownMenuItem>}
|
||||
{(item.canEdit || viewer?.admin) && <DropdownMenuItem variant="destructive" disabled={pending || (viewer?.admin && !item.canModerate)} onClick={() => setDeleting(true)}><Trash2Icon />ลบ</DropdownMenuItem>}
|
||||
{viewer?.admin && <DropdownMenuItem disabled={pending || !item.canBanAuthor} onClick={() => item.authorBanned ? void mutate("ban", false) : setBanning(true)}>
|
||||
{item.authorBanned ? <ShieldCheckIcon /> : <BanIcon />}{item.authorBanned ? "ยกเลิกแบนผู้เขียน" : "แบนผู้เขียน"}
|
||||
</DropdownMenuItem>}
|
||||
</DropdownMenuGroup></DropdownMenuContent>
|
||||
</DropdownMenu>;
|
||||
return <article id={`comment-${item.id}`} tabIndex={highlighted ? -1 : undefined} className={cn("flex scroll-mt-36 flex-col gap-2 py-2", inbox && "grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_280px] md:gap-6", highlighted && "rounded-lg bg-accent ring-2 ring-ring", expanded && !item.rootId && "relative before:absolute before:bottom-8 before:left-5 before:top-12 before:border-l before:border-border", fullWidthMobile && "max-sm:before:hidden")}>
|
||||
const Container = inbox ? TableRow : "article";
|
||||
const Content = inbox ? TableCell : "div";
|
||||
return <Container id={`comment-${item.id}`} tabIndex={highlighted ? -1 : undefined} className={cn("scroll-mt-36", inbox ? "max-md:block" : "flex flex-col gap-2 py-2", highlighted && "rounded-lg bg-accent ring-2 ring-ring", expanded && !item.rootId && "relative before:absolute before:bottom-8 before:left-5 before:top-12 before:border-l before:border-border", fullWidthMobile && "max-sm:before:hidden")}>
|
||||
|
||||
<Content className={cn("min-w-0", inbox && "px-0 py-5 align-top whitespace-normal max-md:block max-md:pb-3 md:pr-6")}>
|
||||
<div className={cn("flex min-w-0 items-start gap-3", fullWidthMobile && "grid grid-cols-[auto_minmax(0,1fr)] sm:flex")}>
|
||||
<Avatar className={item.rootId ? "size-8 shrink-0" : "size-10 shrink-0"}><AvatarImage src={item.authorImage ?? undefined} alt={item.authorName} /><AvatarFallback>{item.authorName.slice(0, 2)}</AvatarFallback></Avatar>
|
||||
<div className={cn("flex min-w-0 flex-1 flex-col gap-2", fullWidthMobile && "max-sm:contents")}>
|
||||
@@ -255,14 +264,21 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{inbox && <div className="flex items-start gap-2 md:justify-end">{optionsMenu}{overview}</div>}
|
||||
</Content>
|
||||
{inbox && <TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pt-0">
|
||||
<div className="flex items-start gap-2 md:justify-end">{optionsMenu}{overview}</div>
|
||||
</TableCell>}
|
||||
<Dialog open={history} onOpenChange={setHistory}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>ประวัติการแก้ไข</DialogTitle><DialogDescription>ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น</DialogDescription></DialogHeader>
|
||||
{history && <RevisionHistory id={item.id} />}</DialogContent></Dialog>
|
||||
<Dialog open={deleting} onOpenChange={setDeleting}><DialogContent><DialogHeader><DialogTitle>ลบความคิดเห็นนี้?</DialogTitle><DialogDescription>ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้</DialogDescription></DialogHeader>
|
||||
<div className="flex justify-end gap-2"><Button variant="ghost" disabled={pending} onClick={() => setDeleting(false)}>ยกเลิก</Button><Button variant="destructive" disabled={pending} onClick={() => void mutate("delete")}>ลบ</Button></div>
|
||||
</DialogContent></Dialog>
|
||||
<AlertDialog open={banning} onOpenChange={(open) => { if (!pending) setBanning(open); }}><AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>แบน {item.authorName}?</AlertDialogTitle><AlertDialogDescription>ผู้เขียนจะถูกออกจากระบบและไม่สามารถเข้าสู่ระบบ แสดงความคิดเห็น หรือใช้งานบัญชีได้ จนกว่าผู้ดูแลจะยกเลิกแบน ความคิดเห็นเดิมจะยังอยู่</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel className="max-md:min-h-11" disabled={pending}>ยกเลิก</AlertDialogCancel><AlertDialogAction variant="destructive" className="max-md:min-h-11" disabled={pending} onClick={() => void mutate("ban", true)}><BanIcon data-icon="inline-start" />แบนผู้เขียน</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent></AlertDialog>
|
||||
|
||||
</article>;
|
||||
</Container>;
|
||||
}
|
||||
|
||||
function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown>; moderationDetails?: boolean }) {
|
||||
|
||||
@@ -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 <div role="status" aria-label="กำลังโหลดความคิดเห็น" className="flex min-w-0 flex-col">
|
||||
<span className="sr-only">กำลังโหลดความคิดเห็น…</span>
|
||||
<div aria-hidden="true">{Array.from({ length: count }, (_, index) => <div key={index}>
|
||||
<div className="grid min-w-0 grid-cols-1 items-start gap-3 py-5 md:grid-cols-[minmax(0,1fr)_280px] md:gap-6">
|
||||
<div className="flex min-w-0 flex-col gap-3 py-2">
|
||||
<div className="flex items-center gap-3"><Skeleton className="size-10 shrink-0 rounded-full" /><Skeleton className="h-4 w-36 max-w-full" /></div>
|
||||
<Skeleton className="h-4 w-4/5" /><Skeleton className="h-4 w-3/5" />
|
||||
<div className="flex gap-3"><Skeleton className="h-11 w-16 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /></div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 py-2 md:justify-end">
|
||||
<Skeleton className="size-11 shrink-0 rounded-full md:size-7" />
|
||||
<Skeleton className="size-28 shrink-0 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>)}</div>
|
||||
<Table className="table-fixed max-md:block" aria-hidden="true">
|
||||
<TableBody className="max-md:block">{Array.from({ length: count }, (_, index) => <TableRow key={index} className="max-md:block">
|
||||
<TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pb-3 md:pr-6">
|
||||
<div className="flex min-w-0 flex-col gap-3 py-2">
|
||||
<div className="flex items-center gap-3"><Skeleton className="size-10 shrink-0 rounded-full" /><Skeleton className="h-4 w-36 max-w-full" /></div>
|
||||
<Skeleton className="h-4 w-4/5" /><Skeleton className="h-4 w-3/5" />
|
||||
<div className="flex gap-3"><Skeleton className="h-11 w-16 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /></div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pt-0 md:w-[280px]">
|
||||
<div className="flex items-start gap-2 md:justify-end">
|
||||
<Skeleton className="size-11 shrink-0 rounded-full md:size-7" />
|
||||
<Skeleton className="size-28 shrink-0 rounded-lg" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>)}</TableBody>
|
||||
</Table>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AuditAction, string> = {
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
comment: {} as Record<string, unknown>, author: {} as Record<string, unknown>,
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) => ({ 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: "[email protected]", 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();
|
||||
});
|
||||
});
|
||||
+11
-1
@@ -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<typeof parseCommentForm>;
|
||||
type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number };
|
||||
type Writer = Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0];
|
||||
|
||||
async function requireCommentPosting(db: Pick<Writer, "select">, 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<ReturnType<typeof getMediaStorage>>, image: Upload, bytes: Uint8Array) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
@@ -49,6 +56,7 @@ async function saveRevision(tx: Pick<Writer, "insert" | "select">, 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;
|
||||
|
||||
@@ -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<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`,
|
||||
@@ -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<T>(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));
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface CommentItem {
|
||||
authorName: string;
|
||||
authorImage: string | null;
|
||||
authorAdmin: boolean;
|
||||
authorBanned?: boolean;
|
||||
canBanAuthor?: boolean;
|
||||
text: string;
|
||||
images: CommentImage[];
|
||||
version: number;
|
||||
|
||||
@@ -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}$/),
|
||||
|
||||
Reference in New Issue
Block a user