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

+387 -10
View File
@@ -43,9 +43,11 @@ import {
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
import { Settings } from "./settings";
import { Catalogs } from "./catalogs";
import { appealable, caseKinds, Cases, type Case, type CaseKind } from "./cases";
import { Notifications } from "./notifications";
import { assignableCatalogRoles, Forbidden, hubScope, Permissions, roles as roleBundles, type Scope } from "./permissions";
const { user, session, imvuLink, linkChallenge, audit, hostedRoom, catalog, product } = schema;
const { user, session, imvuLink, linkChallenge, audit, hostedRoom, catalog, product, hosting } = schema;
export type Deps = {
db: Database;
@@ -179,6 +181,20 @@ export function createApp({
},
});
const limiter = new Limiter();
const cases = new Cases(db, perms);
const notifications = new Notifications(db, (userId, message) => realtime.notify(userId, message));
/** Tells a product's creator that someone else changed it. */
const productEdited = async (me: Person, id: string, creatorId: string, kind: string, fields: string[], reason: string) => {
const [p] = await db.select({ name: product.name }).from(product).where(eq(product.id, id));
await notifications.add(
creatorId,
"product_edited",
{ product: id, name: p?.name ?? "", kind, fields, reason, by: me.displayUsername ?? me.username ?? "" },
{ product: id },
me.id,
);
};
const personView = (p: Person) => ({
id: p.id,
@@ -391,7 +407,79 @@ export function createApp({
url: `${(webURL ?? "").replace(/\/+$/, "")}/catalogs/${i.catalogId}?invite=${i.code}`,
});
type ConsoleSection = "overview" | "products" | "catalogs" | "people" | "audit" | "settings";
type ConsoleSection = "overview" | "products" | "catalogs" | "cases" | "people" | "audit" | "settings";
const caseParam = t.Object({ id: t.Numeric({ minimum: 1, multipleOf: 1 }) });
/** Whether the person may know who opened a case: anyone, but for reports only the reporter and moderators. */
const seesOpener = (me: Person, c: Case) => c.kind !== "report" || c.openedBy === me.id || isModerator(me);
/** A case as a person sees it (reports keep who reported private: seesOpener). */
const caseView = (c: Case, names: Record<string, string>, me: Person) => ({
id: c.id,
kind: c.kind as CaseKind,
status: c.status as "open" | "resolved" | "declined",
tags: c.tags,
tagNames: Object.fromEntries(Object.values(c.tags).filter((v): v is string => !!v).map((v) => [v, names[v] ?? v])),
openedBy: c.openedBy && seesOpener(me, c) ? (names[c.openedBy] ?? "") : "",
assignee: c.assignee ? (names[c.assignee] ?? "") : null,
reason: c.reason,
data: c.kind === "appeal" ? { audit: c.data.audit, action: c.data.action, detail: c.data.detail } : {},
resolution: c.resolution,
decidedBy: c.decidedBy ? (names[c.decidedBy] ?? "") : null,
decidedAt: c.decidedAt?.toISOString() ?? null,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
});
/** A case the person may see, else 404. */
const visibleCase = async (me: Person, id: number) => {
const c = await productCall(() => cases.get(id));
if (!(await cases.canSee(me, c))) throw new Problem(404, "not_found", "No such case.");
return c;
};
/** Whether an audit entry is an action the person may appeal: it affected them (their product, their catalog, or
* them), and someone else did it. */
const affectedBy = async (me: Person, e: { action: string; actorId: string | null; tags: AuditTags }) => {
const who = appealable[e.action];
if (!who || !e.actorId || e.actorId === me.id) return false;
if (who === "person") return e.tags.person === me.id;
if (who === "creator" && e.tags.product) {
const [p] = await db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, e.tags.product));
return p?.creatorId === me.id;
}
if (who === "owner" && e.tags.catalog) {
const [c] = await db.select({ ownerId: catalog.ownerId }).from(catalog).where(eq(catalog.id, e.tags.catalog));
return c?.ownerId === me.id;
}
return false;
};
/** What resolving a case does: lowers the rating, removes the product, deletes the catalog. Reports and appeals
* record the decision; the moderator acts on them as they would anywhere. */
const caseEffect = async (me: Person, c: Case, resolution: string) => {
if (c.kind === "rating-review" && c.tags.product) {
const id = c.tags.product;
const creatorId = await creatorOf(id);
const reason = resolution || "Rating review accepted.";
const done = await productCall(() =>
products.updateInfo(me.id, id, { rating: "general" }, reason, { creator: false, editLocked: true, rateLower: true }),
);
if (done.entry) {
await audited(me.id, "product.edit", id, { fields: done.fields, reason, case: c.id }, { product: id, person: creatorId });
await productEdited(me, id, creatorId, "edit", done.fields, reason);
}
} else if (c.kind === "removal-request" && c.tags.product) {
const reason = resolution || c.reason;
const removed = await productCall(() => products.removeByModeration(c.tags.product!, reason));
await audited(me.id, "product.remove", removed.id, { reason, case: c.id }, { product: removed.id, person: removed.creatorId });
await notifications.add(removed.creatorId, "product_removed", { product: removed.id, name: removed.name, reason }, { product: removed.id }, me.id);
} else if (c.kind === "catalog-deletion" && c.tags.catalog) {
const deleted = await productCall(() => catalogs.markDeleted(c.tags.catalog!, resolution || c.reason));
await audited(me.id, "catalog.delete", deleted.id, { name: deleted.name, case: c.id });
}
};
/** What a person controls: the catalogs they're on the team of, their products, their hosted rooms. */
const consoleScope = async (me: Person) => ({
@@ -451,6 +539,7 @@ export function createApp({
id: audit.id,
at: audit.at,
actor: user.username,
actorId: audit.actorId,
action: audit.action,
target: audit.target,
tags: audit.tags,
@@ -472,7 +561,9 @@ export function createApp({
]);
const names: Record<string, string> = {};
for (const x of [...cs, ...ps, ...people]) names[x.id] = x.name ?? "";
return rows.map((r) => ({
const rowsAppealable = await Promise.all(rows.map((r) => affectedBy(me, { ...r, actorId: r.actorId })));
return rows.map((r, i) => ({
appealable: rowsAppealable[i]!,
id: r.id,
at: r.at.toISOString(),
actor: r.actor ?? "",
@@ -699,7 +790,9 @@ export function createApp({
const scope = await consoleScope(person);
console = isModerator(person) || scope.catalogs.length + scope.products.length + scope.rooms.length > 0;
}
return { person: { ...personView(me), picture: await picture(me.id), console } };
return {
person: { ...personView(me), picture: await picture(me.id), console, unread: await notifications.unread(me.id) },
};
},
{ account: true },
)
@@ -1367,6 +1460,21 @@ export function createApp({
},
{ signedIn: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
)
// Deleting a catalog goes to moderators for now (docs/console.md): its owner opens a catalog-deletion case.
.post(
"/catalogs/:id/delete",
async ({ me, params, body }) => {
await need(me, "catalog:delete-request", catalogScope(params.id));
const c = await productCall(() => catalogs.get(params.id));
if (c.status === "deleted") throw new Problem(409, "deleted", "That catalog is deleted already.");
const open = await cases.findOpen("catalog-deletion", { catalog: c.id });
if (open) throw new Problem(409, "requested", "Its deletion was requested already.", { case: open.id });
const kase = await cases.open("catalog-deletion", me.id, { catalog: c.id }, body.reason);
await audited(me.id, "case.open", String(kase.id), { kind: kase.kind }, kase.tags);
return { case: kase.id };
},
{ signedIn: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 2000 }) }) },
)
.post(
"/catalogs/:id/reinstate",
async ({ me, params }) => {
@@ -1488,6 +1596,10 @@ export function createApp({
person: p.id,
});
realtime.notify(p.id, { type: "library" });
if (before !== body.status) {
const c = await catalogs.get(params.id);
await notifications.add(p.id, "membership_decided", { catalog: c.id, name: c.name, status: body.status }, { catalog: c.id }, me.id);
}
return { membership: body.status };
},
{
@@ -1507,6 +1619,10 @@ export function createApp({
before === "requested" ? "catalog.member_decline" : before === "banned" ? "catalog.member_unban" : "catalog.member_remove";
await audited(me.id, action, params.id, { person: p.username }, { catalog: params.id, person: p.id });
realtime.notify(p.id, { type: "library" });
if (before === "requested") {
const c = await catalogs.get(params.id);
await notifications.add(p.id, "membership_decided", { catalog: c.id, name: c.name, status: "declined" }, { catalog: c.id }, me.id);
}
set.status = 204;
},
{
@@ -1684,17 +1800,35 @@ export function createApp({
const creatorId = await creatorOf(params.id);
await need(me, "product:edit", productScope(params.id));
const { reason = "", ...input } = body;
const done = await productCall(async () =>
products.updateInfo(me.id, params.id, input, reason, await editRights(me, params.id, creatorId)),
);
if (done.entry)
const rights = await editRights(me, params.id, creatorId);
// Lowering a rating without product:rate-lower asks moderators: a rating-review case (one at a time).
let review: number | null = null;
if (input.rating === "general" && !rights.rateLower) {
const [cur] = await db.select({ rating: product.rating }).from(product).where(eq(product.id, params.id));
if (cur?.rating === "adult") {
await productCall(() => products.checkFilesGeneral(params.id));
delete input.rating;
const open = await cases.findOpen("rating-review", { product: params.id });
if (open) review = open.id;
else {
const c = await cases.open("rating-review", me.id, { product: params.id, person: creatorId }, reason || "Lower the rating to general.");
await audited(me.id, "case.open", String(c.id), { kind: c.kind }, c.tags);
review = c.id;
}
}
}
const done = await productCall(async () => products.updateInfo(me.id, params.id, input, reason, rights));
if (done.entry) {
await audited(me.id, "product.edit", params.id, { fields: done.fields, ...(reason ? { reason } : {}) }, {
product: params.id,
person: creatorId,
});
await productEdited(me, params.id, creatorId, "edit", done.fields, reason);
}
return {
product: productView(done.product, undefined, undefined, await products.latestParent(done.product)),
fields: done.fields,
review,
};
},
{
@@ -1773,6 +1907,7 @@ export function createApp({
{ entry: params.entry, fields: done.fields, ...(reason ? { reason } : {}) },
{ product: params.id, person: creatorId },
);
await productEdited(me, params.id, creatorId, "revert", done.fields, reason);
return {
product: productView(done.product, undefined, undefined, await products.latestParent(done.product)),
fields: done.fields,
@@ -1795,6 +1930,7 @@ export function createApp({
product: p.id,
person: creatorId,
});
await productEdited(me, p.id, creatorId, "lock", p.locked, body.reason.trim());
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
},
{
@@ -1830,12 +1966,29 @@ export function createApp({
.post(
"/products/:id/remove",
async ({ me, params, body }) => {
await productCall(() => products.removeByModeration(params.id, body.reason.trim()));
await audited(me.id, "product.remove", params.id, { reason: body.reason.trim() });
const removed = await productCall(() => products.removeByModeration(params.id, body.reason.trim()));
await audited(me.id, "product.remove", params.id, { reason: body.reason.trim() }, {
product: params.id,
person: removed.creatorId,
});
await notifications.add(removed.creatorId, "product_removed", { product: removed.id, name: removed.name, reason: body.reason.trim() }, { product: removed.id }, me.id);
return { ok: true };
},
{ moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
)
// Moderation restores a removed product (an upheld appeal, a mistake).
.post(
"/products/:id/unremove",
async ({ me, params, body }) => {
await need(me, "product:unremove", productScope(params.id));
const p = await productCall(() => products.unremove(params.id));
const reason = body.reason?.trim() ?? "";
await audited(me.id, "product.unremove", p.id, reason ? { reason } : {}, { product: p.id, person: p.creatorId });
await notifications.add(p.creatorId, "product_restored", { product: p.id, name: p.name, reason }, { product: p.id }, me.id);
return { ok: true };
},
{ signedIn: true, params: productParam, body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }) },
)
.post(
"/products/:id/revisions/:n/retract",
async ({ me, params }) => {
@@ -2019,6 +2172,227 @@ export function createApp({
{ params: t.Object({ provider: t.String() }), parse: "none" },
)
// Cases: reports, reviews, requests and appeals (docs/console.md).
.get(
"/cases",
async ({ me, query }) => {
const rows = await cases.list(me, { ...query, mine: query.mine === "1" });
const names = await cases.names(rows);
return {
cases: rows.map((c) => caseView(c, names, me)),
before: rows.length ? rows[rows.length - 1]!.id : null,
moderator: isModerator(me),
};
},
{
signedIn: true,
query: t.Object({
status: t.Optional(t.Union([t.Literal("open"), t.Literal("resolved"), t.Literal("declined")])),
kind: t.Optional(t.Union(caseKinds.map((k) => t.Literal(k)))),
mine: t.Optional(t.Literal("1")),
before: t.Optional(t.Numeric({ minimum: 1, multipleOf: 1 })),
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
}),
},
)
.post(
"/cases",
async ({ me, body, set }) => {
const reason = body.reason.trim();
let c: Case;
if (body.kind === "report") {
const about = body.about ?? {};
const tags: AuditTags = {};
if (about.product) tags.product = (await productCall(() => products.get(about.product!, true, productViewer(me)))).product.id;
else if (about.catalog) tags.catalog = (await productCall(() => catalogs.get(about.catalog!))).id;
else if (about.room) {
const [r] = await db.select({ n: hostedRoom.n }).from(hostedRoom).where(eq(hostedRoom.n, Number(about.room)));
if (!r) throw new Problem(404, "not_found", "No such room.");
tags.room = String(r.n);
} else if (about.person) tags.person = (await personByUsername(about.person)).id;
else throw new Problem(400, "invalid", "Say what you're reporting: a product, catalog, room or person.");
if (tags.person === me.id) throw new Problem(400, "invalid", "You can't report yourself.");
c = await productCall(() => cases.report(me.id, tags, reason));
} else if (body.kind === "removal-request") {
// A catalog's team asks moderators to remove a product it hosts everywhere.
if (!body.about?.product || !body.catalog) throw new Problem(400, "invalid", "Name the product and your catalog.");
await need(me, "hosting:delist", catalogScope(body.catalog));
const [h] = await db
.select({ id: hosting.productId })
.from(hosting)
.where(and(eq(hosting.catalogId, body.catalog), eq(hosting.productId, body.about.product)))
.limit(1);
if (!h) throw new Problem(404, "not_found", "Your catalog doesn't host that product.");
if (!reason) throw new Problem(400, "reason_needed", "Say why it should be removed.");
const tags = { product: body.about.product, catalog: body.catalog };
const open = await cases.findOpen("removal-request", { product: body.about.product });
if (open) throw new Problem(409, "requested", "Its removal was requested already.", { case: open.id });
c = await cases.open("removal-request", me.id, tags, reason);
} else {
// An appeal of an action that affected the person (one per action).
if (!body.audit) throw new Problem(400, "invalid", "Say which action you're appealing.");
const [entry] = await db.select().from(audit).where(eq(audit.id, body.audit));
if (!entry || !(await affectedBy(me, entry))) throw new Problem(404, "not_found", "No such action of yours to appeal.");
if (!reason) throw new Problem(400, "reason_needed", "Say why it should be undone.");
const [already] = await db
.select({ id: schema.moderationCase.id })
.from(schema.moderationCase)
.where(and(eq(schema.moderationCase.kind, "appeal"), sql`${schema.moderationCase.data}->>'audit' = ${String(entry.id)}`));
if (already) throw new Problem(409, "appealed", "You appealed that already.", { case: already.id });
c = await cases.open("appeal", me.id, entry.tags, reason, {
audit: String(entry.id),
action: entry.action,
actorId: entry.actorId,
detail: entry.detail,
});
}
// A report's entry isn't tagged with what it's about: who reported stays private (the reporter still sees it).
await audited(me.id, "case.open", String(c.id), { kind: c.kind }, c.kind === "report" ? {} : c.tags);
set.status = 201;
return { case: caseView(c, await cases.names([c]), me) };
},
{
signedIn: true,
body: t.Object({
kind: t.Union([t.Literal("report"), t.Literal("removal-request"), t.Literal("appeal")]),
about: t.Optional(
t.Object({
product: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
catalog: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
room: t.Optional(t.String({ pattern: "^[0-9]{1,12}$" })),
person: t.Optional(t.String({ maxLength: 64 })),
}),
),
catalog: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
audit: t.Optional(t.Integer({ minimum: 1 })),
reason: t.String({ maxLength: 2000 }),
}),
},
)
.get(
"/cases/:id",
async ({ me, params }) => {
const c = await visibleCase(me, params.id);
const decide = await cases.canDecide(me, c);
return {
case: caseView(c, await cases.names([c]), me),
notes: (await cases.notes(c, decide || isModerator(me))).map((n) => ({
id: n.id,
by: n.byId === c.openedBy && !seesOpener(me, c) ? "Reporter" : (n.by ?? ""),
byOpener: n.byId === c.openedBy,
body: n.body,
internal: n.internal,
at: n.at.toISOString(),
})),
canDecide: decide && c.status === "open",
canAssign: perms.hub(me).has("case:assign"),
};
},
{ signedIn: true, params: caseParam },
)
.post(
"/cases/:id/notes",
async ({ me, params, body }) => {
const c = await visibleCase(me, params.id);
const decide = await cases.canDecide(me, c);
const internal = body.internal === true && (decide || isModerator(me));
await productCall(() => cases.addNote(c, me.id, body.body, internal));
if (!internal) {
// The opener hears from those deciding; the assignee hears from the opener.
const to = me.id === c.openedBy ? c.assignee : c.openedBy;
await notifications.add(to, "case_note", { case: c.id, kind: c.kind, by: me.displayUsername ?? me.username ?? "" }, c.tags, me.id);
}
return { ok: true };
},
{
signedIn: true,
params: caseParam,
body: t.Object({ body: t.String({ maxLength: 4000 }), internal: t.Optional(t.Boolean()) }),
},
)
.post(
"/cases/:id/assign",
async ({ me, params, body }) => {
await need(me, "case:assign", hubScope);
const c = await visibleCase(me, params.id);
const to = body.to ? await personByUsername(body.to) : null;
if (to && !perms.hub(to).has("case:resolve")) throw new Problem(400, "invalid", "Assign cases to moderators.");
const updated = await cases.assign(c, to?.id ?? null);
await audited(me.id, "case.assign", String(c.id), { to: to?.username ?? null }, c.kind === "report" ? {} : c.tags);
return { case: caseView(updated, await cases.names([updated]), me) };
},
{ signedIn: true, params: caseParam, body: t.Object({ to: t.Union([t.String({ maxLength: 64 }), t.Null()]) }) },
)
.post(
"/cases/:id/decide",
async ({ me, params, body }) => {
const c = await visibleCase(me, params.id);
if (c.status !== "open") throw new Problem(409, "decided", "That case is decided already.");
if (!(await cases.canDecide(me, c)))
throw new Problem(
403,
"forbidden",
c.kind === "appeal" && c.data.actorId === me.id
? "Another moderator decides appeals of what you did."
: "You can't decide this case.",
);
const resolution = body.resolution.trim();
if (body.decision === "resolve") await caseEffect(me, c, resolution);
const decided = await productCall(() => cases.decide(c, me.id, body.decision === "resolve" ? "resolved" : "declined", resolution));
await audited(
me.id,
"case.decide",
String(c.id),
{
kind: c.kind,
status: decided.status,
...(resolution ? { resolution } : {}),
...(c.kind === "appeal" && c.data.actorId === me.id ? { ownAction: true } : {}),
},
c.kind === "report" ? {} : c.tags,
);
await notifications.add(c.openedBy, "case_decided", { case: c.id, kind: c.kind, status: decided.status, resolution }, c.tags, me.id);
return { case: caseView(decided, await cases.names([decided]), me) };
},
{
signedIn: true,
params: caseParam,
body: t.Object({
decision: t.Union([t.Literal("resolve"), t.Literal("decline")]),
resolution: t.String({ maxLength: 2000 }),
}),
},
)
// Notifications.
.get(
"/notifications",
async ({ me, query }) => {
const rows = await notifications.list(me.id, query.before);
return {
notifications: rows.map((n) => ({
id: n.id,
type: n.type,
tags: n.tags,
data: n.data,
at: n.createdAt.toISOString(),
read: n.readAt !== null,
})),
unread: await notifications.unread(me.id),
before: rows.length === 50 ? rows[rows.length - 1]!.id : null,
};
},
{ signedIn: true, query: t.Object({ before: t.Optional(t.Numeric({ minimum: 1, multipleOf: 1 })) }) },
)
.post(
"/notifications/read",
async ({ me, body }) => {
await notifications.markRead(me.id, body.ids);
return { unread: await notifications.unread(me.id) };
},
{ signedIn: true, body: t.Object({ ids: t.Optional(t.Array(t.Integer({ minimum: 1 }), { maxItems: 200 })) }) },
)
// The console.
.get(
"/console/products",
@@ -2081,6 +2455,8 @@ export function createApp({
const sections: ConsoleSection[] = ["overview"];
if (mod || scope.products.length) sections.push("products");
if (mod || scope.catalogs.length) sections.push("catalogs");
if (mod || scope.catalogs.length || scope.products.length || (await cases.list(me, { limit: 1 })).length)
sections.push("cases");
if (mod) sections.push("people");
if (sections.length > 1 || scope.rooms.length) sections.push("audit");
if (me.role === "admin") sections.push("settings");
@@ -2110,6 +2486,7 @@ export function createApp({
mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length },
todo: {
drafts: scope.products.length ? await products.draftCount(me.id) : 0,
cases: await cases.openToDecide(me),
memberRequests: requests?.n ?? 0,
pendingCatalogs: moderation?.counts.pendingCatalogs ?? 0,
waitingPeople: moderation?.counts.waiting ?? 0,
+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;
}
}
+13
View File
@@ -122,6 +122,19 @@ export class Catalogs {
return updated!;
}
/** Deletes a catalog (after a moderator accepted its deletion request): it stays on record as deleted, nothing
* is available from it, and it learns so from its changes feed. */
async markDeleted(id: string, reason: string) {
await this.get(id);
const [updated] = await this.db
.update(catalog)
.set({ status: "deleted", statusReason: reason })
.where(eq(catalog.id, id))
.returning();
await this.change(id, { type: "status", status: "deleted" });
return updated!;
}
// ---- teams ----
async addRole(catalogId: string, personId: string, role: string, by: string) {
+59
View File
@@ -0,0 +1,59 @@
// Notifications (docs/console.md): something for a person to know, shown on the website and sent to their proxies
// over the realtime connection ({"type": "notification"}), so a client can show them later.
import { and, count, desc, eq, inArray, isNull, lt, schema, type AuditTags, type Database } from "@t5de/db";
const { notification } = schema;
/**
* The kinds of notification, and what their data holds:
* - product_edited: someone other than its creator changed a product's info `{product, name, kind, fields, reason, by}`
* - case_decided: a case they opened was decided `{case, kind, status, resolution}`
* - case_note: someone replied on a case they opened or are assigned `{case, kind, by}`
* - product_removed: moderation removed their product `{product, name, reason}`
* - product_restored: moderation restored it `{product, name, reason}`
* - membership_decided: a catalog decided their membership `{catalog, name, status}` (member, declined, banned)
*/
export type NotificationType = "product_edited" | "product_removed" | "product_restored" | "case_decided" | "case_note" | "membership_decided";
export class Notifications {
constructor(
private readonly db: Database,
private readonly push: (userId: string, message: object) => void = () => {},
) {}
/** Tells a person something (never the one who did it: pass `actorId` to leave them out). */
async add(userId: string | null | undefined, type: NotificationType, data: Record<string, unknown>, tags: AuditTags = {}, actorId?: string) {
if (!userId || userId === actorId) return;
await this.db.insert(notification).values({ userId, type, data, tags });
this.push(userId, { type: "notification" });
}
/** A person's notifications, newest first; `before` an ID for the next page. */
async list(userId: string, before?: number, limit = 50) {
const rows = await this.db
.select()
.from(notification)
.where(and(eq(notification.userId, userId), before ? lt(notification.id, before) : undefined))
.orderBy(desc(notification.id))
.limit(Math.max(1, Math.min(limit, 100)));
return rows;
}
async unread(userId: string): Promise<number> {
const [row] = await this.db
.select({ n: count() })
.from(notification)
.where(and(eq(notification.userId, userId), isNull(notification.readAt)));
return row?.n ?? 0;
}
/** Marks some (or all) of a person's notifications read. */
async markRead(userId: string, ids?: number[]) {
await this.db
.update(notification)
.set({ readAt: new Date() })
.where(
and(eq(notification.userId, userId), isNull(notification.readAt), ids ? inArray(notification.id, ids) : undefined),
);
}
}
+31 -10
View File
@@ -392,6 +392,18 @@ export class Products {
return p;
}
/** Undoes a moderation removal (an upheld appeal, a mistake): its page, files and library entries are back, since
* removing only hides them. */
async unremove(id: string) {
const [p] = await this.db
.update(product)
.set({ removedAt: null, removedReason: null })
.where(and(eq(product.id, id), sql`${product.removedAt} is not null`))
.returning();
if (!p) throw new ProductError(404, "not_found", "No such removed product.");
return p;
}
/**
* Published products, newest first; adult ones only if adult; unlisted ones only for their creator. `category`:
* only those whose latest revision's parent is in that IMVU category (anywhere in its path); `tag`: those with
@@ -459,9 +471,10 @@ export class Products {
.where(eq(product.id, id));
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (row.product.status === "draft" && !(viewer?.moderator || viewer?.id === row.product.creatorId))
throw new ProductError(404, "not_found", "No such product.");
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
// Its creator and moderators always see it: drafts, and adult products without an adult account.
const responsible = viewer?.moderator || viewer?.id === row.product.creatorId;
if (row.product.status === "draft" && !responsible) throw new ProductError(404, "not_found", "No such product.");
if (row.product.rating === "adult" && !adult && !responsible) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db
.select()
.from(productRevision)
@@ -549,13 +562,7 @@ export class Products {
if (changes.rating?.to === "general") {
if (!rights.rateLower)
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
const [adultFiles] = await this.db
.select({ n: productRevision.number })
.from(productRevision)
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"), eq(productRevision.retracted, false)))
.limit(1);
if (adultFiles)
throw new ProductError(400, "files_adult", `Revision ${adultFiles.n}'s files are rated adult; retract it first.`);
await this.checkFilesGeneral(id);
}
return this.db.transaction(async (tx) => {
@@ -568,6 +575,17 @@ export class Products {
});
}
/** Refuses (400 files_adult) if a revision of the product that isn't retracted is rated adult. */
async checkFilesGeneral(id: string) {
const [adultFiles] = await this.db
.select({ n: productRevision.number })
.from(productRevision)
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"), eq(productRevision.retracted, false)))
.limit(1);
if (adultFiles)
throw new ProductError(400, "files_adult", `Revision ${adultFiles.n}'s files are rated adult; retract it first.`);
}
/** A draft becomes published: listed (unless unlisted), and people can get it. */
async publish(byId: string, id: string) {
const p = await this.editable(id, { creator: true, editLocked: false, rateLower: false });
@@ -783,6 +801,8 @@ export class Products {
async add(personId: string, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
// Seeing an adult product is open to its creator and moderators; getting it isn't.
if (p.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
if (p.product.status !== "published") throw new ProductError(409, "draft", "This product isn't published yet.");
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product.");
@@ -834,6 +854,7 @@ export class Products {
*/
async revisionFor(personId: string | undefined, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
if (p.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
const want = pin ?? p.product.latest;
const usable = p.revisions.filter((r) => !r.retracted);
+306
View File
@@ -0,0 +1,306 @@
// Cases (docs/console.md): reports, rating reviews, deletion and removal requests, appeals; their threads, who sees
// and decides them, what deciding does; and notifications.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
// Catalog addresses that answer /health (for address changes).
const healthy = new Set<string>(["https://moved.example.test"]);
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({
db,
auth,
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
apiURL,
webURL,
catalogHealthy: async (url) => healthy.has(url),
});
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; name: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
}
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
/** A person's catalog, registered and approved; its key's Authorization header. */
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
const reg = await call(owner, "POST", "/api/v1/catalogs", {
name,
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
contact: "[email protected]",
visibility: "public",
membership,
});
expect(reg.status).toBe(201);
const id = reg.json.catalog.id as string;
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
}
/** A product uploaded by a person to a catalog; its ID. */
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const r = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
c.auth,
);
expect(r.status).toBe(201);
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
return r.json.product as string;
}
let admin: Person;
let mod: Person;
const notes = async (p: Person) => (await call(p, "GET", "/api/v1/notifications")).json;
test("the first account is the admin; a moderator", async () => {
admin = await person("admin1");
mod = await person("warden");
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
});
test("reports: anyone signed in reports; the hosting catalog's team and moderators decide; who reported stays private", async () => {
const owner = await person("shelfowner");
const maker = await person("maker");
const reporter = await person("reporter");
const outsider = await person("outsider");
const c = await catalogOf(owner, admin, "Report Shelf");
const hat = await upload(maker, c, "Odd Hat", 1);
expect((await call(undefined, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "x" })).status).toBe(401);
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: " " })).json.error).toBe("reason_needed");
const made = await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Copied from another creator" });
expect(made.status).toBe(201);
const id = made.json.case.id;
expect(made.json.case).toMatchObject({ kind: "report", status: "open", tags: { product: hat }, tagNames: { [hat]: "Odd Hat" } });
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Again" })).json.error).toBe("already_reported");
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: "reporter" }, reason: "Me" })).status).toBe(400);
// Who sees it: the reporter, the hosting catalog's team, moderators. Not its creator, not others.
const ids = async (p: Person) => (await call(p, "GET", "/api/v1/cases")).json.cases.map((x: any) => x.id);
expect(await ids(reporter)).toEqual([id]);
expect(await ids(owner)).toContain(id);
expect(await ids(mod)).toContain(id);
expect(await ids(maker)).not.toContain(id);
expect((await call(maker, "GET", `/api/v1/cases/${id}`)).status).toBe(404);
expect((await call(outsider, "GET", `/api/v1/cases/${id}`)).status).toBe(404);
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: true, canAssign: false });
expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: false, case: { openedBy: "reporter" } });
// The catalog's team decides it without knowing who reported; moderators know.
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("");
expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("reporter");
expect((await call(owner, "GET", "/api/v1/console")).json.todo.cases).toBe(1);
expect((await call(owner, "GET", "/api/v1/console")).json.sections).toContain("cases");
// A thread: replies reach the reporter; internal notes stay with those deciding.
expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Looking into it" })).status).toBe(200);
expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Probably fine", internal: true })).status).toBe(200);
expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => n.body)).toEqual(["Looking into it"]);
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => [n.body, n.internal])).toEqual([
["Looking into it", false],
["Probably fine", true],
]);
expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_note", data: { case: id, by: "shelfowner" } });
// Assigning is for moderators, to moderators.
expect((await call(owner, "POST", `/api/v1/cases/${id}/assign`, { to: "shelfowner" })).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "outsider" })).json.error).toBe("invalid");
expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "warden" })).json.case.assignee).toBe("warden");
// Deciding: the catalog's owner declines it; the reporter is told; it's done.
expect((await call(outsider, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404);
const decided = await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "It's an original." });
expect(decided.json.case).toMatchObject({ status: "declined", resolution: "It's an original.", decidedBy: "shelfowner" });
expect((await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).json.error).toBe("decided");
expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_decided", data: { case: id, status: "declined" } });
expect((await call(reporter, "GET", "/api/v1/me")).json.person.unread).toBe(2);
expect((await call(reporter, "POST", "/api/v1/notifications/read", {})).json.unread).toBe(0);
// The audit log keeps who reported out of what the product's creator can read.
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${hat}`)).json.entries.map((e: any) => e.action);
expect(audit).not.toContain("case.open");
// Reports are limited: ten an hour.
for (let i = 0; i < 9; i++) {
const p = await person(`spam${i}`);
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: p.name }, reason: "Spam" })).status).toBe(201);
}
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { catalog: c.id }, reason: "One more" })).status).toBe(429);
});
test("rating reviews: a creator lowering a rating asks moderators; resolving lowers it", async () => {
const owner = await person("reviewshelf");
const maker = await person("rater");
const c = await catalogOf(owner, admin, "Review Shelf");
const top = await upload(maker, c, "Plain Top", 2);
expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "adult" })).json.review).toBeNull();
// Lowering it opens a case (one at a time); other fields in the same edit still change.
const asked = await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general", name: "Plain Tee", reason: "Nothing adult about it" });
expect(asked.status).toBe(200);
expect(asked.json.product).toMatchObject({ rating: "adult", name: "Plain Tee" });
const review = asked.json.review as number;
expect(review).toBeGreaterThan(0);
expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general" })).json.review).toBe(review);
const kase = (await call(maker, "GET", `/api/v1/cases/${review}`)).json;
expect(kase.case).toMatchObject({ kind: "rating-review", reason: "Nothing adult about it", openedBy: "rater" });
expect(kase.canDecide).toBe(false);
// Its catalog's team doesn't see or decide ratings; moderators do.
expect((await call(owner, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404);
expect((await call(mod, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "Agreed" })).json.case.status).toBe("resolved");
const history = (await call(maker, "GET", `/api/v1/products/${top}/history`)).json.entries;
expect(history[0]).toMatchObject({ by: "warden", reason: "Agreed", changes: { rating: { from: "adult", to: "general" } } });
const types = (await notes(maker)).notifications.map((n: any) => n.type);
expect(types).toContain("case_decided");
expect(types).toContain("product_edited");
// Files rated adult can't be lowered by anyone: the creator is told at once.
const lace = await upload(maker, c, "Lace", 3, "adult");
expect((await call(maker, "PATCH", `/api/v1/products/${lace}`, { rating: "general" })).json.error).toBe("files_adult");
});
test("a catalog's deletion goes to moderators; resolving it deletes the catalog", async () => {
const owner = await person("leaver");
const operator = await person("leaverop");
const c = await catalogOf(owner, admin, "Leaving Shelf");
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/leaverop?role=catalog-operator`, { role: "catalog-operator" })).status).toBe(200);
expect((await call(operator, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Done" })).json.missing.permission).toBe("catalog:delete-request");
const first = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Closing shop" })).json.case;
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Again" })).json).toMatchObject({ error: "requested", case: first });
// Its team sees the case; a decline leaves the catalog as it was.
expect((await call(operator, "GET", `/api/v1/cases/${first}`)).json.case.kind).toBe("catalog-deletion");
expect((await call(mod, "POST", `/api/v1/cases/${first}/decide`, { decision: "decline", resolution: "Move your products first" })).status).toBe(200);
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("approved");
const second = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Moved them" })).json.case;
expect((await call(mod, "POST", `/api/v1/cases/${second}/decide`, { decision: "resolve", resolution: "" })).status).toBe(200);
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("deleted");
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
});
test("removal requests: a catalog's team asks; moderators remove the product everywhere", async () => {
const owner = await person("cautious");
const maker = await person("copier");
const c = await catalogOf(owner, admin, "Careful Shelf");
const shoes = await upload(maker, c, "Copied Shoes", 4);
expect((await call(maker, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "x" })).status).toBe(403);
const asked = await call(owner, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "Stolen work" });
expect(asked.status).toBe(201);
expect((await call(owner, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(403);
expect((await call(mod, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "Confirmed" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${shoes}`)).status).toBe(410);
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { product: shoes, reason: "Confirmed" } });
});
test("appeals: the person affected appeals an action once; another moderator decides", async () => {
const owner = await person("appealshelf");
const maker = await person("appealer");
const c = await catalogOf(owner, admin, "Appeal Shelf");
const coat = await upload(maker, c, "Fine Coat", 5);
expect((await call(mod, "POST", `/api/v1/products/${coat}/remove`, { reason: "Looks stolen" })).status).toBe(200);
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { name: "Fine Coat", reason: "Looks stolen" } });
// The creator's audit log offers the removal for appeal; others can't appeal it.
const entry = (await call(maker, "GET", `/api/v1/console/audit?product=${coat}`)).json.entries.find((e: any) => e.action === "product.remove");
expect(entry.appealable).toBe(true);
expect((await call(owner, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "x" })).status).toBe(404);
const appeal = await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "I made it from scratch" });
expect(appeal.json.case).toMatchObject({ kind: "appeal", data: { action: "product.remove" } });
expect((await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "Again" })).json.error).toBe("appealed");
// The moderator who removed it doesn't decide the appeal; an admin may (flagged in the audit log when it was theirs).
const id = appeal.json.case.id;
expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.canDecide).toBe(false);
expect((await call(mod, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "" })).json.message).toContain("Another moderator");
expect((await call(admin, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "Restoring it" })).json.case.status).toBe("resolved");
// Upholding it records the decision; the moderator restores the product (only moderators can).
expect((await call(maker, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, { reason: "Appeal upheld" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${coat}`)).status).toBe(200);
expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(404);
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_restored", data: { reason: "Appeal upheld" } });
});
test("membership decisions and moderators' edits reach people as notifications", async () => {
const owner = await person("gatekeeper");
const joiner = await person("joiner");
const c = await catalogOf(owner, admin, "Gated Shelf", "approval");
expect((await call(joiner, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/joiner`, { status: "member" })).status).toBe(200);
expect((await notes(joiner)).notifications[0]).toMatchObject({ type: "membership_decided", data: { catalog: c.id, status: "member" } });
const maker = await person("edited");
const p = await upload(maker, c, "Wrong Name", 6);
expect((await call(mod, "PATCH", `/api/v1/products/${p}`, { name: "Right Name", reason: "Typo" })).status).toBe(200);
expect((await notes(maker)).notifications[0]).toMatchObject({
type: "product_edited",
data: { product: p, name: "Right Name", fields: ["name"], reason: "Typo", by: "warden" },
});
// Their own edits don't notify them.
const before = (await notes(maker)).notifications.length;
await call(maker, "PATCH", `/api/v1/products/${p}`, { tags: ["mine"] });
expect((await notes(maker)).notifications.length).toBe(before);
});
+3 -3
View File
@@ -297,10 +297,10 @@ test("the console is for everyone who controls something; its audit log shows on
expect((await call(nobody, "GET", "/api/v1/console")).status).toBe(403);
expect((await call(nobody, "GET", "/api/v1/console/audit")).status).toBe(403);
const ownerConsole = (await call(owner, "GET", "/api/v1/console")).json;
expect(ownerConsole.sections).toEqual(["overview", "catalogs", "audit"]);
expect(ownerConsole.sections).toEqual(["overview", "catalogs", "cases", "audit"]);
expect(ownerConsole.moderation).toBeNull();
expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "audit"]);
expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "people", "audit", "settings"]);
expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "cases", "audit"]);
expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "cases", "people", "audit", "settings"]);
// The owner sees their catalog's entries, not the other catalog's; the maker sees their product's.
const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries;
+10 -3
View File
@@ -189,12 +189,16 @@ test("catalogs register uploads; products have revisions, info and libraries", a
await call(maker, "POST", `/api/v1/products/${adultID}/publish`);
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
// Its creator and moderators see it without an adult account; getting or wearing it still needs one.
expect((await call(admin, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(admin, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
expect((await call(admin, "GET", `/api/v1/products/${adultID}/files`)).status).toBe(404);
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]);
expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]);
// Lowering a rating waits for review.
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("review_needed");
// Its files are rated adult, so it can't be lowered (a revision rated general can be reviewed: cases.test.ts).
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("files_adult");
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
@@ -404,7 +408,10 @@ test("product info: moderators edit with reasons and lock fields; history and re
// Ratings: anyone with product:edit raises it; lowering is for moderators, and not below its files' rating.
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "adult" })).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "general" })).json.error).toBe("review_needed");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "general" })).json).toMatchObject({
product: { rating: "adult" },
review: expect.any(Number),
});
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { rating: "general", reason: "Not adult" })).status).toBe(200);
// The history: every change, newest first, with who and why. Only the creator and moderators read it.
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ReportForm } from "@/components/cases";
import { membership as membershipLabel } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
@@ -95,6 +96,7 @@ export default async function CatalogPage({
Manage
</Link>
)}
{me && !manage && <ReportForm about="catalog" id={id} />}
</div>
);
}
+19 -1
View File
@@ -1,7 +1,10 @@
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { DateTime } from "@/components/time";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { AuditEntry } from "@/lib/api";
import { appeal } from "./cases/actions";
const tagLinks: Record<string, (id: string) => string> = {
catalog: (id) => `/console/catalogs/${id}`,
@@ -59,7 +62,22 @@ export function AuditTable({ entries, empty = "Nothing yet." }: { entries: Audit
})}
{Object.keys(e.tags).length === 0 && e.target}
</TableCell>
<TableCell className="text-xs whitespace-normal text-muted-foreground">{detailText(e.detail)}</TableCell>
<TableCell className="text-xs whitespace-normal text-muted-foreground">
{detailText(e.detail)}
{e.appealable && (
<details className="mt-1">
<summary className="cursor-pointer text-foreground underline-offset-4 hover:underline">Appeal</summary>
<ActionForm action={appeal} fields={{ audit: e.id }} className="mt-2">
<div className="flex gap-2">
<Input name="reason" required maxLength={2000} placeholder="Why it should be undone" aria-label="Why" className="h-8 min-w-48" />
<SubmitButton size="sm" variant="outline" pendingLabel="Sending…">
Send
</SubmitButton>
</div>
</ActionForm>
</details>
)}
</TableCell>
</TableRow>
))}
</TableBody>
+191
View File
@@ -0,0 +1,191 @@
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>
</>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use server";
// Cases (reports, reviews, requests, appeals) and notifications, from the website.
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
/** Reports a product, catalog, room or person. */
export async function report(_: ActionState, form: FormData): Promise<ActionState> {
const kind = field(form, "about");
const id = field(form, "id");
if (!["product", "catalog", "room", "person"].includes(kind)) return { error: "Nothing to report." };
return attempt(async () => {
unwrap(await api.cases.post({ kind: "report", about: { [kind]: id }, reason: field(form, "reason") }));
revalidatePath("/console/cases");
return "Reported. Moderators will look at it; you'll be told what they decide.";
});
}
/** Appeals an action from the audit log. */
export async function appeal(_: ActionState, form: FormData): Promise<ActionState> {
let id = 0;
const state = await attempt(async () => {
const { case: c } = unwrap(await api.cases.post({ kind: "appeal", audit: Number(field(form, "audit")), reason: field(form, "reason") }));
id = c.id;
});
if (state?.error) return state;
redirect(`/console/cases/${id}`);
}
/** A catalog's team asks moderators to remove a product everywhere. */
export async function requestRemoval(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
unwrap(
await api.cases.post({
kind: "removal-request",
about: { product: field(form, "product") },
catalog: field(form, "catalog"),
reason: field(form, "reason"),
}),
);
revalidatePath("/console/cases");
return "Asked. Moderators will decide.";
});
}
/** A catalog's owner asks for it to be deleted. */
export async function requestDeletion(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
let kase = 0;
const state = await attempt(async () => {
kase = unwrap(await api.catalogs({ id }).delete.post({ reason: field(form, "reason") })).case;
});
if (state?.error) return state;
redirect(`/console/cases/${kase}`);
}
const onCase = (form: FormData) => {
const id = Number(field(form, "case"));
return { id, done: () => revalidatePath(`/console/cases/${id}`) };
};
export async function addNote(_: ActionState, form: FormData): Promise<ActionState> {
const { id, done } = onCase(form);
return attempt(async () => {
unwrap(await api.cases({ id }).notes.post({ body: field(form, "body"), internal: form.get("internal") === "on" }));
done();
});
}
export async function assign(_: ActionState, form: FormData): Promise<ActionState> {
const { id, done } = onCase(form);
return attempt(async () => {
const to = field(form, "to");
unwrap(await api.cases({ id }).assign.post({ to: to || null }));
done();
});
}
export async function decide(_: ActionState, form: FormData): Promise<ActionState> {
const { id, done } = onCase(form);
const decision = field(form, "decision");
if (decision !== "resolve" && decision !== "decline") return { error: "Choose to resolve or decline it." };
return attempt(async () => {
unwrap(await api.cases({ id }).decide.post({ decision, resolution: field(form, "resolution") }));
done();
revalidatePath("/console/cases");
revalidatePath("/console");
});
}
export async function markAllRead(_: ActionState): Promise<ActionState> {
return attempt(async () => {
unwrap(await api.notifications.read.post({}));
revalidatePath("/notifications");
revalidatePath("/", "layout");
});
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CaseAbout, caseKinds, CaseStatus } from "@/components/cases";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Cases" };
const statuses = ["open", "resolved", "declined"] as const;
const kinds = ["report", "rating-review", "removal-request", "catalog-deletion", "appeal"] as const;
export default async function CasesPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
await requireConsole("/console/cases");
const params = await searchParams;
// Open cases unless asked otherwise ("any" for all).
const status = params.status === "any" ? undefined : (statuses.find((s) => s === params.status) ?? "open");
const kind = kinds.find((k) => k === params.kind);
const mine = params.mine === "1";
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
const data = unwrap(
await api.cases.get({
query: {
...(status ? { status } : {}),
...(kind ? { kind } : {}),
...(mine ? { mine: "1" as const } : {}),
...(before ? { before } : {}),
limit: 50,
},
}),
);
const older = new URLSearchParams({
status: status ?? "any",
...(kind ? { kind } : {}),
...(mine ? { mine: "1" } : {}),
...(data.before ? { before: String(data.before) } : {}),
});
return (
<>
<PageHeader title="Cases">
{data.moderator ? "Reports, reviews, requests and appeals." : "Cases you opened, and those about what you manage."}
</PageHeader>
<ConsoleNav />
<form action="/console/cases" className="mb-6 flex flex-wrap gap-2">
<NativeSelect name="status" defaultValue={status ?? "any"} aria-label="Status" className="w-36">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="resolved">Resolved</NativeSelectOption>
<NativeSelectOption value="declined">Declined</NativeSelectOption>
<NativeSelectOption value="any">Any status</NativeSelectOption>
</NativeSelect>
<NativeSelect name="kind" defaultValue={kind ?? ""} aria-label="Kind" className="w-44">
<NativeSelectOption value="">Any kind</NativeSelectOption>
{kinds.map((k) => (
<NativeSelectOption key={k} value={k}>
{caseKinds[k]}
</NativeSelectOption>
))}
</NativeSelect>
<NativeSelect name="mine" defaultValue={mine ? "1" : ""} aria-label="Whose" className="w-44">
<NativeSelectOption value="">{data.moderator ? "Everyone's" : "All I can see"}</NativeSelectOption>
<NativeSelectOption value="1">Opened by me</NativeSelectOption>
</NativeSelect>
<Button type="submit">Filter</Button>
</form>
<Card>
<CardContent>
{data.cases.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing here.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Case</TableHead>
<TableHead>About</TableHead>
<TableHead>Opened by</TableHead>
<TableHead>Assigned</TableHead>
<TableHead>Status</TableHead>
<TableHead>Changed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.cases.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">
<Link href={`/console/cases/${c.id}`} className="underline-offset-4 hover:underline">
{caseKinds[c.kind] ?? c.kind} #{c.id}
</Link>
</TableCell>
<TableCell className="text-sm">
<CaseAbout tags={c.tags} names={c.tagNames} />
</TableCell>
<TableCell className="text-muted-foreground">{c.openedBy || "someone"}</TableCell>
<TableCell className="text-muted-foreground">{c.assignee ?? ""}</TableCell>
<TableCell>
<CaseStatus status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={c.updatedAt} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{data.before && data.cases.length === 50 && (
<div className="mt-4">
<Link href={`/console/cases?${older}`} className={buttonVariants({ variant: "outline" })}>
Older
</Link>
</div>
)}
</>
);
}
@@ -14,6 +14,7 @@ import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type CatalogMember, type CatalogPage } from "@/lib/api";
import { AuditTable } from "../../audit-table";
import { requestDeletion, requestRemoval } from "../../cases/actions";
import { ConsoleNav, requireConsole } from "../../console-nav";
import {
addOperator,
@@ -357,6 +358,21 @@ export default async function ManageCatalog({ params }: { params: Promise<{ id:
</SubmitButton>
</div>
</ActionForm>
{!p.removed && (
<details className="mt-1 text-left text-xs">
<summary className="cursor-pointer text-right text-muted-foreground hover:text-foreground">
Ask moderators to remove it everywhere
</summary>
<ActionForm action={requestRemoval} fields={{ catalog: id, product: p.id }} className="mt-2">
<div className="flex justify-end gap-2">
<Input name="reason" placeholder="Why" aria-label="Why" required maxLength={2000} className="h-8 w-48" />
<SubmitButton size="sm" variant="outline" pendingLabel="Asking…">
Ask
</SubmitButton>
</div>
</ActionForm>
</details>
)}
</TableCell>
)}
</TableRow>
@@ -394,6 +410,28 @@ export default async function ManageCatalog({ params }: { params: Promise<{ id:
</Card>
)}
{has("catalog:delete-request") && c.status !== "deleted" && (
<Card>
<CardHeader>
<CardTitle>Delete Catalog</CardTitle>
<CardDescription>
Moderators review deletions for now. Once deleted, nothing is available from it; people who got its products
keep them only where other catalogs host them.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={requestDeletion} fields={{ id }}>
<div className="flex max-w-xl gap-2">
<Input name="reason" placeholder="Why" aria-label="Why" required maxLength={2000} />
<SubmitButton variant="destructive" pendingLabel="Asking…" confirm={`Ask moderators to delete ${c.name}?`}>
Ask to delete
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Recent Changes</CardTitle>
+1
View File
@@ -37,6 +37,7 @@ const tabs: { section: Section; href: string; label: string }[] = [
{ section: "overview", href: "/console", label: "Overview" },
{ section: "products", href: "/console/products", label: "Products" },
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
{ section: "cases", href: "/console/cases", label: "Cases" },
{ section: "people", href: "/console/people", label: "People" },
{ section: "audit", href: "/console/audit", label: "Audit" },
];
+1
View File
@@ -19,6 +19,7 @@ export default async function Console() {
const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
const todo: Stat[] = [
{ label: "Cases", value: data.todo.cases, note: "open, for you to decide", href: "/console/cases" },
{ label: "Drafts", value: data.todo.drafts, note: "to review and publish", href: "/console/products?status=draft" },
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
];
+14 -1
View File
@@ -8,7 +8,9 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ActionForm, SubmitButton } from "@/components/forms";
import { api, unwrap } from "@/lib/api";
import { restoreProduct } from "../../products/actions";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Products" };
@@ -112,10 +114,21 @@ export default async function ConsoleProducts({ searchParams }: { searchParams:
<DateTime iso={p.updatedAt} dateOnly />
</TableCell>
<TableCell className="text-right">
{!p.removed && (
{!p.removed ? (
<Link href={`/products/${p.id}/edit`} className={buttonVariants({ size: "sm", variant: "outline" })}>
{p.status === "draft" ? "Review" : "Edit"}
</Link>
) : (
data.moderator && (
<ActionForm action={restoreProduct} fields={{ id: p.id }}>
<div className="flex justify-end gap-2">
<Input name="reason" placeholder="Reason" aria-label="Reason" maxLength={500} className="h-8 w-36" />
<SubmitButton size="sm" variant="outline" pendingLabel="Restoring…" confirm={`Restore ${p.name}?`}>
Restore
</SubmitButton>
</div>
</ActionForm>
)
)}
</TableCell>
</TableRow>
+117
View File
@@ -0,0 +1,117 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { caseKinds } from "@/components/cases";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { api, getMe, unwrap, type NotificationEntry } from "@/lib/api";
import { cn } from "@/lib/utils";
import { markAllRead } from "../console/cases/actions";
export const metadata: Metadata = { title: "Notifications" };
export default async function Notifications({ searchParams }: { searchParams: Promise<{ before?: string | string[] }> }) {
const me = await getMe();
if (!me) redirect("/signin?next=/notifications");
const raw = (await searchParams).before;
const before = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : undefined;
const data = unwrap(await api.notifications.get({ query: before ? { before } : {} }));
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Notifications">
{data.unread > 0 ? `${data.unread} unread.` : "You're up to date."}
</PageHeader>
{data.unread > 0 && (
<ActionForm action={markAllRead}>
<SubmitButton variant="outline" pendingLabel="Marking…">
Mark all as read
</SubmitButton>
</ActionForm>
)}
</div>
<Card>
<CardContent>
{data.notifications.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing yet.</p>
) : (
<ul className="divide-y">
{data.notifications.map((n) => {
const { text, href } = describe(n);
return (
<li key={n.id} className={cn("flex flex-wrap items-baseline gap-x-3 gap-y-1 py-3 first:pt-0 last:pb-0", n.read && "text-muted-foreground")}>
{!n.read && <span className="size-2 shrink-0 self-center rounded-full bg-foreground" aria-label="Unread" />}
<span className="min-w-0 flex-1 text-sm">
{href ? (
<Link href={href} className="underline-offset-4 hover:underline">
{text}
</Link>
) : (
text
)}
</span>
<span className="text-xs text-muted-foreground">
<DateTime iso={n.at} />
</span>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
{data.before && (
<div className="mt-4">
<Link href={`/notifications?before=${data.before}`} className={buttonVariants({ variant: "outline" })}>
Older
</Link>
</div>
)}
</>
);
}
/** A notification in words, and where it leads. */
function describe(n: NotificationEntry): { text: string; href?: string } {
const d = n.data as Record<string, unknown>;
const s = (k: string) => (typeof d[k] === "string" ? (d[k] as string) : "");
const why = s("reason") ? `: “${s("reason")}”` : "";
switch (n.type) {
case "product_edited": {
const what =
s("kind") === "lock"
? "changed the locks on"
: s("kind") === "revert"
? "undid a change to"
: `changed the ${(d.fields as string[] | undefined)?.join(", ") ?? "info"} of`;
return { text: `${s("by") || "A moderator"} ${what} ${s("name")}${why}`, href: `/products/${s("product")}/edit` };
}
case "product_removed":
return { text: `Moderation removed ${s("name")}${why}. You can appeal it from your audit log.`, href: `/console/audit?product=${s("product")}` };
case "product_restored":
return { text: `Moderation restored ${s("name")}${why}`, href: `/products/${s("product")}` };
case "case_decided": {
const status = s("status") === "resolved" ? "resolved" : "declined";
const res = s("resolution") ? `: “${s("resolution")}”` : "";
return { text: `Your ${caseKinds[s("kind")]?.toLowerCase() ?? "case"} was ${status}${res}`, href: `/console/cases/${d.case}` };
}
case "case_note":
return { text: `${s("by") || "Someone"} replied on your ${caseKinds[s("kind")]?.toLowerCase() ?? "case"}`, href: `/console/cases/${d.case}` };
case "membership_decided": {
const status = s("status");
const text =
status === "member"
? `You're a member of ${s("name")}`
: status === "banned"
? `${s("name")} banned you`
: `${s("name")} declined your request to join`;
return { text, href: `/catalogs/${s("catalog")}` };
}
default:
return { text: n.type };
}
}
+4 -2
View File
@@ -162,7 +162,7 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
<div className="grid gap-6 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
{locked("rating") || (product.rating === "adult" && !can("product:rate-lower")) ? (
{locked("rating") ? (
<p className="text-sm capitalize">{product.rating}</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue={product.rating} className="w-48">
@@ -173,7 +173,9 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
{locked("rating") ? (
<LockedNote />
) : (
<FieldDescription>Raising it to adult is immediate; lowering it needs a moderator.</FieldDescription>
<FieldDescription>
Raising it to adult is immediate; {can("product:rate-lower") ? "lowering it is too." : "lowering it asks moderators."}
</FieldDescription>
)}
</Field>
<Field>
+3
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { ReportForm } from "@/components/cases";
import { formatSize, ProductImage, RatingTag, StateTags } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
@@ -150,6 +151,8 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
</Card>
)}
{me && !mine && !draft && <ReportForm about="product" id={id} />}
{isModerator(me) && (
<Card>
<CardHeader>
+14 -1
View File
@@ -56,6 +56,18 @@ export async function removeProduct(_: ActionState, form: FormData): Promise<Act
redirect("/products");
}
/** Moderation restores a removed product. */
export async function restoreProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
unwrap(await api.products({ id }).unremove.post({ reason: field(form, "reason") }));
done(id);
revalidatePath("/products");
revalidatePath("/console/products");
return "Restored.";
});
}
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
@@ -71,7 +83,7 @@ export async function saveProduct(_: ActionState, form: FormData): Promise<Actio
const rating = field(form, "rating");
const listed = field(form, "listed");
return attempt(async () => {
const { fields } = unwrap(
const { fields, review } = unwrap(
await api.products({ id }).patch({
...(has("name") ? { name: field(form, "name") } : {}),
...(has("description") ? { description: String(form.get("description") ?? "") } : {}),
@@ -92,6 +104,7 @@ export async function saveProduct(_: ActionState, form: FormData): Promise<Actio
done(id);
revalidatePath("/products");
revalidatePath(`/products/${id}/edit`);
if (review) return `${fields.length ? "Saved. " : ""}Moderators will review lowering the rating (case #${review}).`;
return fields.length ? "Saved." : "Nothing changed.";
});
}
+76
View File
@@ -0,0 +1,76 @@
// Pieces shared by the case pages and the places people open cases from.
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { Input } from "@/components/ui/input";
import { report } from "@/app/console/cases/actions";
export const caseKinds: Record<string, string> = {
report: "Report",
"rating-review": "Rating review",
"removal-request": "Removal request",
"catalog-deletion": "Catalog deletion",
appeal: "Appeal",
};
/** What resolving each kind of case does (its button). */
export const resolveLabel: Record<string, string> = {
report: "Resolve",
"rating-review": "Lower to general",
"removal-request": "Remove the product",
"catalog-deletion": "Delete the catalog",
appeal: "Uphold the appeal",
};
export function CaseStatus({ status }: { status: string }) {
if (status === "open") return <Tag tone="strong">Open</Tag>;
if (status === "resolved") return <Tag>Resolved</Tag>;
return <Tag>Declined</Tag>;
}
const tagLinks: Record<string, (id: string) => string> = {
catalog: (id) => `/catalogs/${id}`,
product: (id) => `/products/${id}`,
};
/** What a case is about, linked. */
export function CaseAbout({ tags, names }: { tags: Record<string, string | undefined>; names: Record<string, string> }) {
const parts = Object.entries(tags).filter((e): e is [string, string] => !!e[1]);
if (parts.length === 0) return <span className="text-muted-foreground">nothing</span>;
return (
<span className="space-x-2">
{parts.map(([kind, id]) => {
const href = tagLinks[kind]?.(id);
return (
<span key={kind}>
<span className="text-muted-foreground">{kind} </span>
{href ? (
<Link href={href} className="underline-offset-4 hover:underline">
{names[id] ?? id}
</Link>
) : (
(names[id] ?? id)
)}
</span>
);
})}
</span>
);
}
/** "Report": a reason, sent to moderators (and the hosting catalogs' teams, for products). */
export function ReportForm({ about, id, label = "Report" }: { about: "product" | "catalog" | "room" | "person"; id: string; label?: string }) {
return (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">{label}</summary>
<ActionForm action={report} fields={{ about, id }} className="mt-2">
<div className="flex max-w-xl flex-wrap gap-2">
<Input name="reason" required maxLength={2000} placeholder="What's wrong?" aria-label="What's wrong" className="min-w-60 flex-1" />
<SubmitButton variant="outline" pendingLabel="Sending…">
Send report
</SubmitButton>
</div>
</ActionForm>
</details>
);
}
+2 -2
View File
@@ -2,9 +2,9 @@ import { Tag } from "@/components/status";
export const membership: Record<string, string> = { open: "Open", approval: "By approval", invite: "Invite only" };
/** A catalog's status: approved, pending, rejected or suspended. */
/** A catalog's status: approved, pending, rejected, suspended or deleted. */
export function CatalogStatus({ status }: { status: string }) {
if (status === "approved") return <Tag tone="strong">approved</Tag>;
if (status === "rejected" || status === "suspended") return <Tag tone="danger">{status}</Tag>;
if (status === "rejected" || status === "suspended" || status === "deleted") return <Tag tone="danger">{status}</Tag>;
return <Tag>{status}</Tag>;
}
+16 -1
View File
@@ -1,4 +1,5 @@
import Image from "next/image";
import { Bell } from "lucide-react";
import Link from "next/link";
import { getMe } from "@/lib/api";
import { buttonVariants } from "@/components/ui/button";
@@ -28,7 +29,21 @@ export async function Header() {
Download
</a>
{me ? (
<ProfileMenu username={me.username} picture={me.picture} console={me.console} />
<>
<Link
href="/notifications"
aria-label={me.unread ? `Notifications, ${me.unread} unread` : "Notifications"}
className={buttonVariants({ variant: "ghost", size: "icon", className: "relative" })}
>
<Bell className="size-4" />
{me.unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-semibold text-background tabular-nums">
{me.unread > 99 ? "99+" : me.unread}
</span>
)}
</Link>
<ProfileMenu username={me.username} picture={me.picture} console={me.console} />
</>
) : (
<>
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
+3
View File
@@ -41,6 +41,9 @@ export type AgeInfo = Data<V1["age"]["get"]>;
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
export type ConsoleData = Data<V1["console"]["get"]>;
export type AuditEntry = ConsoleData["audit"][number];
export type CaseSummary = Data<V1["cases"]["get"]>["cases"][number];
export type CasePage = Data<ReturnType<V1["cases"]>["get"]>;
export type NotificationEntry = Data<V1["notifications"]["get"]>["notifications"][number];
export type CatalogPage = Data<ReturnType<V1["catalogs"]>["get"]>;
export type CatalogMember = Data<ReturnType<V1["catalogs"]>["members"]["get"]>["members"][number];
export type CatalogInvite = Data<ReturnType<V1["catalogs"]>["invites"]["get"]>["invites"][number];
+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) |
+46
View File
@@ -0,0 +1,46 @@
CREATE TABLE "case_note" (
"id" serial PRIMARY KEY NOT NULL,
"case_id" integer NOT NULL,
"by_id" text,
"body" text NOT NULL,
"internal" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "moderation_case" (
"id" serial PRIMARY KEY NOT NULL,
"kind" text NOT NULL,
"status" text DEFAULT 'open' NOT NULL,
"tags" jsonb DEFAULT '{}'::jsonb NOT NULL,
"opened_by" text,
"assignee" text,
"reason" text DEFAULT '' NOT NULL,
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
"resolution" text DEFAULT '' NOT NULL,
"decided_by" text,
"decided_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notification" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"tags" jsonb DEFAULT '{}'::jsonb NOT NULL,
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"read_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "case_note" ADD CONSTRAINT "case_note_case_id_moderation_case_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."moderation_case"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "case_note" ADD CONSTRAINT "case_note_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "moderation_case" ADD CONSTRAINT "moderation_case_opened_by_user_id_fk" FOREIGN KEY ("opened_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "moderation_case" ADD CONSTRAINT "moderation_case_assignee_user_id_fk" FOREIGN KEY ("assignee") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "moderation_case" ADD CONSTRAINT "moderation_case_decided_by_user_id_fk" FOREIGN KEY ("decided_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "case_note_case" ON "case_note" USING btree ("case_id","id");--> statement-breakpoint
CREATE INDEX "moderation_case_status" ON "moderation_case" USING btree ("status","id");--> statement-breakpoint
CREATE INDEX "moderation_case_opened_by" ON "moderation_case" USING btree ("opened_by");--> statement-breakpoint
CREATE INDEX "moderation_case_tags" ON "moderation_case" USING gin ("tags");--> statement-breakpoint
CREATE INDEX "notification_user" ON "notification" USING btree ("user_id","id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -85,6 +85,13 @@
"when": 1793886718951,
"tag": "0011_product_info",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1793888724185,
"tag": "0012_cases",
"breakpoints": true
}
]
}
+60
View File
@@ -598,3 +598,63 @@ export const hubSetting = pgTable("hub_setting", {
value: text("value").notNull(),
updatedAt: updatedAt(),
});
// A case: something someone has to decide (docs/console.md, "Cases"): a report, a rating review, a removal request,
// a catalog's deletion request, or an appeal. ("case" is an SQL keyword, hence the table's name.)
export const moderationCase = pgTable(
"moderation_case",
{
id: serial("id").primaryKey(),
kind: text("kind").notNull(), // "report", "rating-review", "removal-request", "catalog-deletion", "appeal"
status: text("status").notNull().default("open"), // "open", "resolved", "declined"
// What it's about ({catalog, product, room, person}), as audit entries are tagged.
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
openedBy: text("opened_by").references(() => user.id, { onDelete: "set null" }),
assignee: text("assignee").references(() => user.id, { onDelete: "set null" }),
reason: text("reason").notNull().default(""), // why it was opened
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}), // per kind (an appeal's audit entry)
resolution: text("resolution").notNull().default(""),
decidedBy: text("decided_by").references(() => user.id, { onDelete: "set null" }),
decidedAt: timestamp("decided_at", { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("moderation_case_status").on(t.status, t.id),
index("moderation_case_opened_by").on(t.openedBy),
index("moderation_case_tags").using("gin", t.tags),
],
);
// A case's thread. Internal notes are only for those deciding it.
export const caseNote = pgTable(
"case_note",
{
id: serial("id").primaryKey(),
caseId: integer("case_id")
.notNull()
.references(() => moderationCase.id, { onDelete: "cascade" }),
byId: text("by_id").references(() => user.id, { onDelete: "set null" }),
body: text("body").notNull(),
internal: boolean("internal").notNull().default(false),
createdAt: createdAt(),
},
(t) => [index("case_note_case").on(t.caseId, t.id)],
);
// Something for a person to know: a moderator edited their product, their case was decided, ... (docs/console.md).
export const notification = pgTable(
"notification",
{
id: serial("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
type: text("type").notNull(),
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
createdAt: createdAt(),
readAt: timestamp("read_at", { withTimezone: true }),
},
(t) => [index("notification_user").on(t.userId, t.id)],
);