Files
t5de-hub/apps/web/app/console/cases/[id]/page.tsx
T
dhkatz 7299e10b08
Release / release (push) Successful in 3s
CI / test (push) Successful in 17m26s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 15s
feat: cases (reports, reviews, requests, appeals) and notifications (#9)
Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.

**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
  - a thread of notes, where internal notes are visible only to those deciding
  - an assignee (moderators assign cases to moderators)
  - a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
  - Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
  - Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
  - Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
  - From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
  - Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
  - Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.

**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
  - a moderator edited or locked your product, or undid a change (with the reason)
  - your product was removed or restored
  - your case was decided
  - someone replied on your case
  - a catalog decided your membership

**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.

**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.

**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)

**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
  - who sees and decides each kind of case, and that the reporter stays private
  - rate limits and duplicate reports
  - the thread and internal notes, and assigning
  - what deciding each kind of case does
  - appeals by the person affected only, decided by another moderator
  - restoring a removed product
  - the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
  - The catalog's team could see who reported.
  - A missing Resolve/Decline choice was treated as Decline; now it's refused.
  - The Unassign button sent the typed name instead of nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.

**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
  - a thread of notes, where internal notes are visible only to those deciding
  - an assignee (moderators assign cases to moderators)
  - a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
  - Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
  - Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
  - Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
  - From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
  - Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
  - Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.

**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
  - a moderator edited or locked your product, or undid a change (with the reason)
  - your product was removed or restored
  - your case was decided
  - someone replied on your case
  - a catalog decided your membership

**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.

**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.

**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)

**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
  - who sees and decides each kind of case, and that the reporter stays private
  - rate limits and duplicate reports
  - the thread and internal notes, and assigning
  - what deciding each kind of case does
  - appeals by the person affected only, decided by another moderator
  - restoring a removed product
  - the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
  - The catalog's team could see who reported.
  - A missing Resolve/Decline choice was treated as Decline; now it's refused.
  - The Unassign button sent the typed name instead of nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 22:14:08 +00: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>
</>
);
}