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]>
232 lines
11 KiB
TypeScript
232 lines
11 KiB
TypeScript
// 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;
|
|
}
|
|
}
|