Files
t5de-hub/apps/web/app/console/cases/actions.ts
T
dhkatzandClaude Opus 5.5 8af4b7b6a9
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
feat: cases (reports, rating reviews, removal and deletion requests, appeals) and notifications
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]>
2026-11-05 07:03:59 -08:00

99 lines
3.6 KiB
TypeScript

"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");
});
}