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

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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 07:03:59 -08:00
1 parent e07a6c6dac
commit 8af4b7b6a9
33 files changed
+5228 -44

No files matched your search

+2 -2
View File
@@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. |
| `POST /api/auth/sign-in/username` | `{username, password}` |
| `POST /api/auth/sign-out` | |
| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null`, and `console`: whether the console is for them |
| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null`, `console`: whether the console is for them, and `unread`: their unread notifications |
| `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` |
## Proxies
@@ -112,7 +112,7 @@ Permissions, roles and scopes are in docs/console.md. A missing permission answe
| | |
| --- | --- |
| `GET /api/v1/console` | (anyone controlling a catalog, product or hosted room, and moderators; else 403) `{sections, mine, todo, moderation, audit}`: `todo` counts what waits on them (`drafts`, `memberRequests`; for moderators `pendingCatalogs`, `waitingPeople`), `moderation` (`{counts, connections, online}`) only for moderators, `audit` the latest 20 entries they may see |
| `GET /api/v1/console` | (anyone controlling a catalog, product or hosted room, and moderators; else 403) `{sections, mine, todo, moderation, audit}`: `todo` counts what waits on them (`cases` they could decide, `drafts`, `memberRequests`; for moderators `pendingCatalogs`, `waitingPeople`), `moderation` (`{counts, connections, online}`) only for moderators, `audit` the latest 20 entries they may see |
| `GET /api/v1/console/audit?catalog=&product=&room=&person=&actor=&action=&q=&from=&to=&before=&limit=` | `{entries, before}`: entries the person may see (docs/console.md), newest first; `action` matches a prefix, `from`/`to` are days, `before` an entry ID for the next page. Entries are `{id, at, actor, action, target, tags, tagNames, detail}` |
| `GET /api/v1/console/permissions?catalog=&product=` | `{roles, permissions}` at that scope (the hub if neither) |
| `GET /api/v1/console/catalogs` | `{catalogs, moderator}`: every catalog for moderators, else those on whose team the person is |
+27 -3
View File
@@ -131,8 +131,22 @@ log), who opened it, a status (`open`, `resolved`, `declined`), an assignee, a r
| `catalog-deletion` | a catalog's owner (deleting goes to review for now) | moderators (`catalog:delete-review`) |
| `appeal` | the person affected by a removal, ban, delisting or decline (one per action) | a different moderator than the one who acted |
- People see cases they opened and cases about what they control (`case:read` at a covering scope).
- People see cases they opened or are assigned, and cases about what they control (`case:read` at a covering scope),
except reports: those only the reporter, the hosting catalogs' teams (reports about products) and moderators see,
and only the reporter and moderators see who reported (a report's audit entry isn't tagged with what it's about).
- Who decides: moderators, by the kind's permission (`case:resolve`; `product:rate-lower` for rating reviews,
`product:remove` for removal requests, `catalog:delete-review` for deletions); reports about a product also the
teams of the catalogs hosting it (`case:resolve` there). An appeal goes to another moderator than the one who acted;
an admin may decide one of their own, which the audit log flags (`ownAction`).
- Resolving a rating review lowers the rating (as an edit, in the product's history); a removal request removes the
product; a deletion request deletes the catalog (status `deleted`; its changes feed says so). Reports and appeals
record the decision, and the moderator acts where it's done: a removed product is restored (`POST
/api/v1/products/:id/unremove`), an edit undone from the history, a catalog reinstated, a member unbanned.
- Appealable actions: `product.remove`, `product.edit`/`lock`/`revert` by others (the creator), `catalog.delist`
(the product's creator), `catalog.suspend`/`reject` (the owner), `catalog.member_ban`/`member_decline` (the
person). The audit log marks the entries a person may appeal (`appealable`).
- Reports are limited per person (10 an hour), one open report per person and resource.
- Not built yet: canned reasons, moderator notes and second approval (below).
- **Canned reasons** (admins edit the list) for removals, bans, declines and edits; free text can be added.
- **Moderator notes**: private notes on people and products (`person:notes`), shown in the console only.
- **Second approval** for the most destructive actions: banning a person, removing a catalog and transferring a
@@ -144,6 +158,10 @@ log), who opened it, a status (`open`, `resolved`, `declined`), an assignee, a r
One table; shown on the website (a bell, unread count), on the proxy's realtime connection
(`{"type": "notification"}`, so the client can show them later), and by email for types the person opts into.
Built (2026-11-05): `product_edited` (by someone other than the creator, with the reason), `product_removed`,
`product_restored`, `case_decided`, `case_note`, `membership_decided`. Not yet: email (the hub sends none yet) and its
settings, and the import, grant, new-revision and request-digest types.
Types: a moderator edited your product (with the reason); a case about you or yours was opened or decided; your
membership request was decided; your import finished or failed; your catalog's deletion request was decided; a
product in your library has a new revision; a grant was made or withdrawn; someone requested your products
@@ -196,8 +214,14 @@ for meaning.
| `GET /api/v1/console/permissions?scope=...` | the person's permissions at a scope (the website uses it to show actions) |
| `GET /api/v1/console/audit?tag=&actor=&action=&from=&to=&q=` | entries the person may see, newest first |
| `GET /api/v1/console/stats?scope=&metric=&from=&to=` | `{series: [{day, value}]}` |
| `GET/POST /api/v1/cases`, `GET/PATCH /api/v1/cases/:id`, `POST /api/v1/cases/:id/notes` | cases |
| `GET /api/v1/notifications`, `POST /api/v1/notifications/read`, `GET/PUT /api/v1/notifications/settings` | notifications |
| `GET /api/v1/cases?status=&kind=&mine=1&before=&limit=` | `{cases, before, moderator}`: the cases the person may see (below), newest first |
| `POST /api/v1/cases` | `{kind, about?, catalog?, audit?, reason}` → 201 `{case}`. `report`: `about` names one product, catalog, room (its number) or person (username); 10 an hour (429 `rate_limited`), one open per person and resource (409 `already_reported`), not yourself. `removal-request`: `about.product`, hosted by `catalog`, whose `hosting:delist` the person needs. `appeal`: `audit`, an entry of an action that affected the person (409 `appealed` if they appealed it already) |
| `GET /api/v1/cases/:id` | `{case, notes, canDecide, canAssign}`; internal notes only for those deciding and moderators |
| `POST /api/v1/cases/:id/notes` | `{body, internal?}`: a note; the opener's reach the assignee and others' reach the opener as notifications (internal ones reach nobody) |
| `POST /api/v1/cases/:id/assign` | (`case:assign`) `{to: username or null}`: a moderator |
| `POST /api/v1/cases/:id/decide` | `{decision: "resolve" or "decline", resolution}`: by those who may decide it (below); resolving carries out a rating review, removal request or deletion request; the opener is told |
| `GET /api/v1/notifications?before=` | `{notifications: [{id, type, tags, data, at, read}], unread, before}`, newest first, 50 a page |
| `POST /api/v1/notifications/read` | `{ids?}` (all if left out) → `{unread}` |
| `GET/POST /api/v1/account/tokens`, `DELETE /api/v1/account/tokens/:id` | API tokens |
| `GET/PUT/DELETE /api/v1/roles/:scopeType/:scopeId/:person` | role assignments at a scope |
| `POST /api/v1/console/view-as/:person` | (admins) start viewing as someone, read-only |
+2 -1
View File
@@ -477,7 +477,8 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md):
within its parent's top-level category; IMVU's own category tree (if it has a public one) would replace those
(to look into with the importer spike).
3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens;
two-factor sign-in for sensitive permissions.
two-factor sign-in for sensitive permissions. Cases and notifications **done** (2026-11-05), without email, canned
reasons, moderator notes or second approval; file blocks, API tokens and two-factor sign-in next.
4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike).
5. Charts and stats; mirroring between catalogs.
+4 -4
View File
@@ -98,7 +98,7 @@ catalog's). Only approved catalogs with imports on and not paused.
| `POST /api/v1/catalogs/:id/importer` | (`import:run`) `{enabled}` → `{key?}`: turns imports on (a new importer key, shown once) or off; `POST .../importer/key` rotates it; `{paused}` pauses |
| `GET /api/v1/catalogs/:id/team`, `PUT/DELETE .../team/:person` | (`team:manage`) `{role: "operator"}` |
| `POST /api/v1/catalogs/:id/transfer` | (`catalog:transfer`) `{to}`: offered to the person, who accepts on their side (`POST .../transfer/accept`) |
| `POST /api/v1/catalogs/:id/delete` | (`catalog:delete-request`) `{reason}`: opens a `catalog-deletion` case; nothing changes until a moderator decides (console.md) |
| `POST /api/v1/catalogs/:id/delete` | (`catalog:delete-request`) `{reason}` → `{case}`: opens a `catalog-deletion` case (409 `requested` if one is open); nothing changes until a moderator decides (console.md). Resolved, the catalog's status is `deleted`: nothing is available from it, and its key stops working |
| `POST /api/v1/catalogs/:id/join` | join an open catalog, or ask to join an approval one; `{invite}` to use an invite code. `DELETE` leaves. |
| `GET /api/v1/catalogs/:id/members?status=` | (`member:approve`) members, requests and bans |
| `PUT /api/v1/catalogs/:id/members/:person` | `{status: "member" \| "banned"}` (`member:approve` / `member:ban`); `DELETE` removes (`member:remove`) |
@@ -108,8 +108,8 @@ catalog's). Only approved catalogs with imports on and not paused.
| `POST /api/v1/catalogs/:id/suspend`, `.../reinstate` | (`catalog:suspend`) `{reason}`: a suspended catalog's products aren't available from it, it can't register revisions, and its team sees why |
| `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL (the catalog's upload policy and rating ceiling apply) |
| `GET /api/v1/products?q=&creator=&category=&catalog=&tag=&limit=` | (anyone) `{products}`, newest first: published and listed (and the person's own unlisted ones); adult ones only for people with an adult account. `category`: an IMVU category ID in its category path (its own if chosen, else its latest revision's parent's; e.g. 106 Clothing, 128 Tops). `tag`: one tag; `catalog`: those the catalog hosts. `limit`: 1-200, default 60 |
| `GET /api/v1/products/:id` | (anyone, adult rule as above; drafts only for their creator and moderators) `{product, revisions, inLibrary?, mine?, available, permissions}` |
| `PATCH /api/v1/products/:id` | (`product:edit`) `{name?, description?, rating?, tags?, listed?, category?, reason?}` → `{product, fields}` (the fields that changed; only those go in the history). Description is HTML, cleaned by the allowlist. Tags: up to 10, lowercased, each up to 24 letters, digits, spaces or dashes. `category`: the ID of one of its category choices (below), or null for its parent's. Rating may go general → adult; adult → general needs `product:rate-lower` (400 `review_needed`; with cases it opens a `rating-review` case instead), and no revision that isn't retracted may be rated adult (400 `files_adult`). `reason` is required when the person isn't the creator (400 `reason_needed`), and shown to them. Locked fields need `product:edit-locked` (403 `locked`). The creator can't edit a product they deleted; moderators can. |
| `GET /api/v1/products/:id` | (anyone, adult rule as above, except that its creator and moderators always see it; drafts only for their creator and moderators; getting and wearing adult products still need an adult account) `{product, revisions, inLibrary?, mine?, available, permissions}` |
| `PATCH /api/v1/products/:id` | (`product:edit`) `{name?, description?, rating?, tags?, listed?, category?, reason?}` → `{product, fields}` (the fields that changed; only those go in the history). Description is HTML, cleaned by the allowlist. Tags: up to 10, lowercased, each up to 24 letters, digits, spaces or dashes. `category`: the ID of one of its category choices (below), or null for its parent's. Rating may go general → adult; adult → general without `product:rate-lower` opens a `rating-review` case for moderators instead (one at a time; its ID is the answer's `review`; the other fields still change), and no revision that isn't retracted may be rated adult (400 `files_adult`). `reason` is required when the person isn't the creator (400 `reason_needed`), and shown to them. Locked fields need `product:edit-locked` (403 `locked`). The creator can't edit a product they deleted; moderators can. |
| `GET /api/v1/products/:id/categories` | (`product:edit`) `{choices, parent}`: the category paths it may be filed under, and its parent's. The choices are every IMVU category path the hub has seen on a revision's parent, and each step of them, under the same top-level category as its parent's (any, if that's unknown). Keeping the top level keeps it the same kind of product: the proxy tells clothing, furniture and rooms apart by category. IMVU's own category tree would replace the seen paths if one turns up. |
| `POST /api/v1/products/:id/publish` | (`product:publish`) a draft becomes published (409 `published` if it is already) |
| `GET /api/v1/products/:id/history` | (`audit:read` at the product: its creator, moderators) the info history, newest first: `{entries: [{id, at, kind ("edit", "revert", "lock", "publish"), by, byCreator, changes: {field: {from, to}}, reason, revertOf}]}` |
@@ -120,7 +120,7 @@ catalog's). Only approved catalogs with imports on and not paused.
| `POST /api/v1/products/:id/hosting` | (`hosting:choose`) `{catalog, revision?}`: ask another catalog to mirror it (every revision, or one); the catalog's `mirrors` setting decides (auto, or its team approves) |
| `POST /api/v1/products/:id/revisions/:n/retract` | (creator) |
| `DELETE /api/v1/products/:id` | (creator) deletes it: no longer listed, and nobody new can get it; people who have it keep it (its page, files and library entries stay, and rooms keep showing it). Its creator can't edit it or add revisions after (until restored). 204 |
| `POST /api/v1/products/:id/remove` | (`product:remove`) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, it leaves libraries and rooms, and catalogs delete its files. `.../unremove` (`product:unremove`) undoes it while catalogs still have the files. |
| `POST /api/v1/products/:id/remove` | (`product:remove`) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, it leaves libraries and rooms, and catalogs delete its files. `.../unremove` (`product:unremove`) `{reason?}` undoes it (for now removal only hides it, so its files and library entries come back). Its creator is told both. |
| `PUT /api/v1/blocks/:sha256` | (`product:remove`) `{reason}`: blocks a file network-wide: revisions with it can't be registered, and products using it are removed. `DELETE` unblocks. |
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products that are available to them, each with the revision to use (products no longer available, e.g. after leaving a catalog, stay in the table but aren't listed until they are again) |
| `PUT /api/v1/library/:id` | get a product (add it): it must be published and available to the person (403 `members_only`, with the catalogs they could join) |