feat: cases (reports, reviews, requests, appeals) and notifications (#9)
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

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]>
This commit was merged in pull request #9.
This commit is contained in:
dhkatz authored and dhkatz committed 2026-11-05 22:14:08 +00:00
1 parent e07a6c6dac
commit 7299e10b08
33 files changed
+5228 -44

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
// Cases (docs/console.md): one queue for everything someone has to decide. Reports, rating reviews, removal
// requests, catalogs' deletion requests and appeals. The routes in app.ts check who may open one and carry out a
// decision's effects; this keeps the cases, their threads, and who may see and decide them.
import { and, count, desc, eq, gt, inArray, lt, or, sql, schema, type AuditTags, type Database } from "@t5de/db";
import { ProductError } from "./products";
import { Permissions, type Permission, type Who } from "./permissions";
const { caseNote, catalog, hosting, moderationCase, product, user } = schema;
export const caseKinds = ["report", "rating-review", "removal-request", "catalog-deletion", "appeal"] as const;
export type CaseKind = (typeof caseKinds)[number];
export type Case = typeof moderationCase.$inferSelect;
/** The permission that decides each kind of case (moderators hold them hub-wide). */
const decidedWith: Record<CaseKind, Permission> = {
report: "case:resolve",
"rating-review": "product:rate-lower",
"removal-request": "product:remove",
"catalog-deletion": "catalog:delete-review",
appeal: "case:resolve",
};
/** The actions people can appeal, and whom they affect: a product's creator, a catalog's owner, or the person. */
export const appealable: Record<string, "creator" | "owner" | "person"> = {
"product.remove": "creator",
"product.edit": "creator",
"product.lock": "creator",
"product.revert": "creator",
"catalog.delist": "creator",
"catalog.suspend": "owner",
"catalog.reject": "owner",
"catalog.member_ban": "person",
"catalog.member_decline": "person",
};
/** Reports a person may open in an hour. */
export const reportsPerHour = 10;
export class Cases {
constructor(
private readonly db: Database,
private readonly perms: Permissions,
) {}
async get(id: number): Promise<Case> {
const [c] = await this.db.select().from(moderationCase).where(eq(moderationCase.id, id));
if (!c) throw new ProductError(404, "not_found", "No such case.");
return c;
}
/** The open case of a kind about a resource (one each), if any; with `openedBy`, only theirs. */
async findOpen(kind: CaseKind, tags: AuditTags, openedBy?: string): Promise<Case | undefined> {
const conds = [eq(moderationCase.kind, kind), eq(moderationCase.status, "open")];
for (const [k, v] of Object.entries(tags)) if (v) conds.push(sql`${moderationCase.tags}->>${k} = ${v}`);
if (openedBy) conds.push(eq(moderationCase.openedBy, openedBy));
const [c] = await this.db.select().from(moderationCase).where(and(...conds)).limit(1);
return c;
}
async open(kind: CaseKind, openedBy: string, tags: AuditTags, reason: string, data: Record<string, unknown> = {}) {
const [c] = await this.db
.insert(moderationCase)
.values({ kind, openedBy, tags, reason: reason.trim().slice(0, 2000), data })
.returning();
return c!;
}
/** A report: at most reportsPerHour an hour, and one open report per person and resource. */
async report(openedBy: string, tags: AuditTags, reason: string) {
if (!reason.trim()) throw new ProductError(400, "reason_needed", "Say what's wrong.");
const since = new Date(Date.now() - 60 * 60 * 1000);
const [recent] = await this.db
.select({ n: count() })
.from(moderationCase)
.where(and(eq(moderationCase.kind, "report"), eq(moderationCase.openedBy, openedBy), gt(moderationCase.createdAt, since)));
if ((recent?.n ?? 0) >= reportsPerHour)
throw new ProductError(429, "rate_limited", "That's a lot of reports. Try again in an hour.");
if (await this.findOpen("report", tags, openedBy))
throw new ProductError(409, "already_reported", "You reported that already; moderators will look at it.");
return this.open("report", openedBy, tags, reason);
}
/** The catalogs hosting a product (whose teams decide reports about it). */
private async hostsOf(productId: string): Promise<string[]> {
const rows = await this.db.selectDistinct({ id: hosting.catalogId }).from(hosting).where(eq(hosting.productId, productId));
return rows.map((r) => r.id);
}
/**
* Whether someone may decide a case: moderators by the kind's permission (an appeal by a different moderator than
* the one who acted, unless an admin, flagged in the audit log); reports about a product also its hosting catalogs'
* teams (case:resolve there).
*/
async canDecide(who: Who, c: Case): Promise<boolean> {
const perm = decidedWith[c.kind as CaseKind];
if (!perm) return false;
if (this.perms.hub(who).has(perm)) return !(c.kind === "appeal" && c.data.actorId === who.id && who.role !== "admin");
if (c.kind === "report" && c.tags.product)
for (const id of await this.hostsOf(c.tags.product))
if (await this.perms.can(who, "case:resolve", { type: "catalog", id })) return true;
return false;
}
/** Whether someone may see a case: its opener, its assignee, those deciding it, and (but for reports, which keep
* who reported private) those with case:read at what it's about. */
async canSee(who: Who, c: Case): Promise<boolean> {
if (c.openedBy === who.id || c.assignee === who.id) return true;
if (this.perms.hub(who).has("case:read") || (await this.canDecide(who, c))) return true;
if (c.kind === "report") return false;
if (c.tags.catalog && (await this.perms.can(who, "case:read", { type: "catalog", id: c.tags.catalog }))) return true;
if (c.tags.product && (await this.perms.can(who, "case:read", { type: "product", id: c.tags.product }))) return true;
return false;
}
/** The cases a person may see (as canSee), newest first. */
async list(who: Who, f: { status?: string; kind?: string; mine?: boolean; before?: number; limit?: number }) {
const conds = [];
if (f.mine) conds.push(eq(moderationCase.openedBy, who.id));
else if (!this.perms.hub(who).has("case:read")) {
const teams = await this.perms.teamCatalogs(who.id);
const own = await this.perms.ownProducts(who.id);
const visible = [eq(moderationCase.openedBy, who.id), eq(moderationCase.assignee, who.id)];
if (teams.length) {
visible.push(and(sql`${moderationCase.kind} <> 'report'`, inArray(sql`${moderationCase.tags}->>'catalog'`, teams))!);
visible.push(
and(
eq(moderationCase.kind, "report"),
sql`exists (select 1 from ${hosting} h where h.product_id = ${moderationCase.tags}->>'product' and h.catalog_id in ${teams})`,
)!,
);
}
if (own.length)
visible.push(and(sql`${moderationCase.kind} <> 'report'`, inArray(sql`${moderationCase.tags}->>'product'`, own))!);
conds.push(or(...visible)!);
}
if (f.status) conds.push(eq(moderationCase.status, f.status));
if (f.kind) conds.push(eq(moderationCase.kind, f.kind));
if (f.before) conds.push(lt(moderationCase.id, f.before));
return this.db
.select()
.from(moderationCase)
.where(conds.length ? and(...conds) : undefined)
.orderBy(desc(moderationCase.id))
.limit(Math.max(1, Math.min(f.limit ?? 50, 200)));
}
/** People's usernames and resources' names for cases (to show and link them). */
async names(cases: Case[]) {
const people = new Set<string>();
const products = new Set<string>();
const catalogs = new Set<string>();
for (const c of cases) {
for (const p of [c.openedBy, c.assignee, c.decidedBy, c.tags.person]) if (p) people.add(p);
if (c.tags.product) products.add(c.tags.product);
if (c.tags.catalog) catalogs.add(c.tags.catalog);
}
const [ps, prs, cs] = await Promise.all([
people.size
? this.db.select({ id: user.id, name: user.displayUsername, username: user.username }).from(user).where(inArray(user.id, [...people]))
: [],
products.size ? this.db.select({ id: product.id, name: product.name }).from(product).where(inArray(product.id, [...products])) : [],
catalogs.size ? this.db.select({ id: catalog.id, name: catalog.name }).from(catalog).where(inArray(catalog.id, [...catalogs])) : [],
]);
const names: Record<string, string> = {};
for (const p of ps) names[p.id] = p.name ?? p.username ?? "";
for (const x of [...prs, ...cs]) names[x.id] = x.name;
return names;
}
async decide(c: Case, by: string, status: "resolved" | "declined", resolution: string) {
if (c.status !== "open") throw new ProductError(409, "decided", "That case is decided already.");
const [updated] = await this.db
.update(moderationCase)
.set({ status, resolution: resolution.trim().slice(0, 2000), decidedBy: by, decidedAt: new Date() })
.where(and(eq(moderationCase.id, c.id), eq(moderationCase.status, "open")))
.returning();
if (!updated) throw new ProductError(409, "decided", "That case is decided already.");
return updated;
}
async assign(c: Case, assignee: string | null) {
const [updated] = await this.db.update(moderationCase).set({ assignee }).where(eq(moderationCase.id, c.id)).returning();
return updated!;
}
async notes(c: Case, internalToo: boolean) {
return this.db
.select({
id: caseNote.id,
by: user.displayUsername,
byId: caseNote.byId,
body: caseNote.body,
internal: caseNote.internal,
at: caseNote.createdAt,
})
.from(caseNote)
.leftJoin(user, eq(user.id, caseNote.byId))
.where(and(eq(caseNote.caseId, c.id), internalToo ? undefined : eq(caseNote.internal, false)))
.orderBy(caseNote.id);
}
async addNote(c: Case, byId: string, body: string, internal: boolean) {
body = body.trim().slice(0, 4000);
if (!body) throw new ProductError(400, "invalid", "Write something.");
await this.db.insert(caseNote).values({ caseId: c.id, byId, body, internal });
await this.db.update(moderationCase).set({ updatedAt: new Date() }).where(eq(moderationCase.id, c.id));
}
/** Open cases a person could decide (for the console's to-dos): every one for moderators, else reports about
* products their catalogs host. */
async openToDecide(who: Who): Promise<number> {
const open = eq(moderationCase.status, "open");
if (this.perms.hub(who).has("case:resolve")) {
const [row] = await this.db.select({ n: count() }).from(moderationCase).where(open);
return row?.n ?? 0;
}
const teams = await this.perms.teamCatalogs(who.id);
if (!teams.length) return 0;
const [row] = await this.db
.select({ n: count() })
.from(moderationCase)
.where(
and(
open,
eq(moderationCase.kind, "report"),
sql`exists (select 1 from ${hosting} h where h.product_id = ${moderationCase.tags}->>'product' and h.catalog_id in ${teams})`,
),
);
return row?.n ?? 0;
}
}