// 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 = { 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 = { "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 { 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 { 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 = {}) { 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 { 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 { 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 { 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(); const products = new Set(); const catalogs = new Set(); 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 = {}; 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 { 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; } }