Files
t5de-hub/apps/web/app/console/cases/[id]/page.tsx
T
dhkatzandClaude Opus 5.5 8af4b7b6a9
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
feat: cases (reports, rating reviews, removal and deletion requests, appeals) and notifications
Cases are one queue for what someone has to decide, each with a thread (internal notes for those deciding), an
assignee and a decision. Reports about a product go to the teams of the catalogs hosting it and to moderators, and
only the reporter and moderators know who reported. A creator lowering a rating opens a rating review; a catalog's
owner asking to delete it opens a deletion request; a catalog's team can ask for a product's removal everywhere; the
person an action affected can appeal it once, decided by another moderator (an admin may decide their own, flagged).
Resolving carries the case out: the rating is lowered, the product removed, the catalog deleted.

Notifications reach people on the website (a bell with the unread count) and their proxies (realtime): moderators'
edits to their products, removals and restores, decisions and replies on their cases, membership decisions.

Also: moderators can restore a removed product, and a product's creator and moderators can see it when it's adult
without an adult account (getting and wearing it still need one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:03:59 -08:00

192 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CaseAbout, caseKinds, CaseStatus, resolveLabel } from "@/components/cases";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireConsole } from "../../console-nav";
import { addNote, assign, decide } from "../actions";
export const metadata: Metadata = { title: "Case" };
/** What each kind of case asks. */
const asks: Record<string, string> = {
report: "Someone reported this.",
"rating-review": "Its creator asks to lower its rating from adult to general.",
"removal-request": "A catalog hosting it asks for it to be removed everywhere.",
"catalog-deletion": "Its owner asks for it to be deleted.",
appeal: "The person it affected asks for this action to be undone.",
};
export default async function CasePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!/^\d{1,9}$/.test(id)) notFound();
const { me } = await requireConsole(`/console/cases/${id}`);
const answer = await api.cases({ id: Number(id) }).get();
if (answer.error) notFound();
const { case: c, notes, canDecide, canAssign } = unwrap(answer);
const appealed = c.kind === "appeal" ? (c.data as { action?: string; detail?: Record<string, unknown> }) : undefined;
return (
<>
<PageHeader title={`${caseKinds[c.kind] ?? c.kind} #${c.id}`}>
<span className="flex flex-wrap items-center gap-2">
<CaseStatus status={c.status} />
<span>
opened by {c.openedBy || "someone"} <DateTime iso={c.createdAt} />
</span>
{c.assignee && <Tag>assigned to {c.assignee}</Tag>}
</span>
</PageHeader>
<ConsoleNav />
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>About</CardTitle>
<CardDescription>{asks[c.kind]}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
<CaseAbout tags={c.tags} names={c.tagNames} />
</p>
{appealed && (
<p>
<span className="text-muted-foreground">Action appealed: </span>
<span className="font-mono text-xs">{appealed.action}</span>
{appealed.detail && Object.keys(appealed.detail).length > 0 && (
<span className="text-muted-foreground">
{" "}
({Object.entries(appealed.detail)
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(", ")}
)
</span>
)}
</p>
)}
{c.reason && <p className="whitespace-pre-line">{c.reason}</p>}
{c.status !== "open" && (
<div className="rounded-lg border p-3">
<p className="font-medium">
{c.status === "resolved" ? "Resolved" : "Declined"} by {c.decidedBy || "a moderator"}{" "}
{c.decidedAt && (
<span className="font-normal text-muted-foreground">
<DateTime iso={c.decidedAt} />
</span>
)}
</p>
{c.resolution && <p className="mt-1 whitespace-pre-line">{c.resolution}</p>}
</div>
)}
</CardContent>
</Card>
{canDecide && (
<Card>
<CardHeader>
<CardTitle>Decide</CardTitle>
<CardDescription>
{c.kind === "report" || c.kind === "appeal"
? "Resolving records the decision; take any action where it's done (removed products are restored from Console › Products, edits undone from the product's history)."
: `Resolving does it: ${resolveLabel[c.kind]?.toLowerCase()}.`}{" "}
{c.openedBy ? `${c.openedBy} is told, with what you write.` : ""}
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={decide} fields={{ case: c.id }}>
<Textarea name="resolution" maxLength={2000} rows={3} placeholder="What you decided, and why" aria-label="Resolution" />
<div className="flex flex-wrap gap-2">
<SubmitButton
name="decision"
value="resolve"
pendingLabel="Saving…"
confirm={c.kind === "catalog-deletion" || c.kind === "removal-request" ? `${resolveLabel[c.kind]}?` : undefined}
>
{resolveLabel[c.kind] ?? "Resolve"}
</SubmitButton>
<SubmitButton name="decision" value="decline" variant="outline" pendingLabel="Saving…">
Decline
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
{canAssign && c.status === "open" && (
<Card>
<CardHeader>
<CardTitle>Assignment</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<ActionForm action={assign} fields={{ case: c.id }} className="flex flex-wrap gap-2 space-y-0">
<Input name="to" defaultValue={c.assignee ?? me.username} maxLength={64} aria-label="Moderator" className="max-w-48" />
<SubmitButton variant="outline" pendingLabel="Saving…">
Assign
</SubmitButton>
</ActionForm>
{c.assignee && (
<ActionForm action={assign} fields={{ case: c.id, to: "" }}>
<SubmitButton variant="ghost" pendingLabel="Saving…">
Unassign
</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Thread</CardTitle>
{canDecide && <CardDescription>Internal notes are only for those deciding.</CardDescription>}
</CardHeader>
<CardContent className="space-y-4">
{notes.length === 0 ? (
<p className="text-sm text-muted-foreground">No notes yet.</p>
) : (
<ul className="divide-y">
{notes.map((n) => (
<li key={n.id} className="space-y-1 py-3 first:pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{n.by || "Someone"}</span>
{n.internal && <Tag>Internal</Tag>}
<span className="text-muted-foreground">
<DateTime iso={n.at} />
</span>
</div>
<p className="text-sm whitespace-pre-line">{n.body}</p>
</li>
))}
</ul>
)}
<ActionForm action={addNote} fields={{ case: c.id }}>
<Textarea name="body" required maxLength={4000} rows={3} placeholder="Write a note" aria-label="Note" />
<div className="flex flex-wrap items-center gap-4">
<SubmitButton variant="outline" pendingLabel="Sending…">
Add note
</SubmitButton>
{canDecide && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="internal" className="size-4 accent-foreground" />
Internal
</label>
)}
</div>
</ActionForm>
</CardContent>
</Card>
<Link href="/console/cases" className="text-sm underline-offset-4 hover:underline">
All cases
</Link>
</div>
</>
);
}