Deleting products: by their creators, and removal by moderation
CI / test (push) Successful in 1m4s

A creator deletes a product: it's no longer listed and nobody new can get it, while people who have it
keep it (its page, files and library entries stay, and rooms keep showing it), as old revisions stay
available. Moderation removes one everywhere, with a reason: its page and files answer 410, and it
leaves libraries and rooms. Both are on the product's page and in the audit log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 08:10:24 -07:00
1 parent 07bbc11565
commit 97ce2be569
12 files changed
+2435 -14

No files matched your search

+21
View File
@@ -268,6 +268,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
parent: latest?.parent ?? 0,
image: latest?.parentInfo?.image ?? null,
categories: latest?.parentInfo?.categories ?? [],
deleted: p.deletedAt !== null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
});
@@ -1155,6 +1156,26 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
}),
},
)
// Its creator deletes a product (no longer listed or added; those who have it keep it).
.delete(
"/products/:id",
async ({ me, params, set }) => {
await productCall(() => products.delete(me.id, params.id));
await audited(me.id, "product.delete", params.id);
set.status = 204;
},
{ signedIn: true, params: productParam },
)
// Moderation removes a product: gone everywhere.
.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() });
return { ok: true };
},
{ moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
)
.post(
"/products/:id/revisions/:n/retract",
async ({ me, params }) => {
+3 -2
View File
@@ -2,7 +2,7 @@
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
// room is realtime.ts's decorated().
import { createHash } from "node:crypto";
import { and, eq, inArray, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
import { hostedBase } from "./realtime";
const { roomDecoration, product, productRevision, imvuLink } = schema;
@@ -55,6 +55,7 @@ export class Decorations {
and(
inArray(product.id, ids),
ne(product.latest, 0),
isNull(product.removedAt),
adult ? undefined : eq(product.rating, "general"),
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
),
@@ -93,7 +94,7 @@ export class Decorations {
const known = await this.db
.select({ id: product.id })
.from(product)
.where(and(inArray(product.id, ids), ne(product.latest, 0)));
.where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt)));
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
}
if (contents.length === 0) {
+25 -3
View File
@@ -296,6 +296,25 @@ export class Products {
const [p] = await this.db.select().from(product).where(eq(product.id, productId));
if (!p) throw new ProductError(404, "not_found", "No such product.");
if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (p.deletedAt) throw new ProductError(410, "deleted", "You deleted this product.");
return p;
}
/** Its creator deletes a product: it's no longer listed or added to libraries (those who have it keep it). */
async delete(personId: string, id: string) {
await this.own(personId, id);
await this.db.update(product).set({ deletedAt: new Date() }).where(eq(product.id, id));
}
/** Moderation removes a product: gone everywhere. */
async removeByModeration(id: string, reason: string) {
const [p] = await this.db
.update(product)
.set({ removedAt: new Date(), removedReason: reason })
.where(and(eq(product.id, id), isNull(product.removedAt)))
.returning();
if (!p) throw new ProductError(404, "not_found", "No such product, or it's removed already.");
return p;
}
@@ -304,7 +323,7 @@ export class Products {
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
*/
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
const conds = [ne(product.latest, 0)];
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)];
if (!opts.adult) conds.push(eq(product.rating, "general"));
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`));
if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
@@ -343,6 +362,7 @@ export class Products {
.innerJoin(user, eq(user.id, product.creatorId))
.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.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db
.select()
@@ -407,7 +427,7 @@ export class Products {
.innerJoin(product, eq(product.id, library.productId))
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(eq(library.userId, personId))
.where(and(eq(library.userId, personId), isNull(product.removedAt)))
.orderBy(desc(library.addedAt));
}
@@ -420,7 +440,9 @@ export class Products {
}
async add(personId: string, productId: string, adult: boolean) {
await this.get(productId, adult);
const p = await this.get(productId, adult);
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product.");
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
}
+35
View File
@@ -278,3 +278,38 @@ test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () =
expect(user("https://evil.example/p.png").picture).toBeNull();
expect(user(undefined).picture).toBeNull();
});
test("creators delete products (those who have them keep them); moderation removes them everywhere", async () => {
const { admin, catalogID, auth } = shared;
const maker = await person("deleter", { id: 511, ageVerified: true });
const owner = await person("keeper", { id: 512, ageVerified: true });
const late = await person("latecomer", { id: 513, ageVerified: true });
const make = async (name: string) => {
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
return r.json.product as string;
};
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
const id = await make("Gone Hat");
expect((await call(owner, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
// Only its creator deletes it; then it's not listed and nobody new adds it, but its page, the library entry and
// the files stay for those who have it.
expect((await call(owner, "DELETE", `/api/v1/products/${id}`)).status).toBe(403);
expect((await call(maker, "DELETE", `/api/v1/products/${id}`)).status).toBe(204);
expect(await listed()).toEqual([]);
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).json.product.deleted).toBe(true);
expect((await call(late, "PUT", `/api/v1/library/${id}`)).json.error).toBe("deleted");
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
expect((await call(owner, "GET", `/api/v1/products/${id}/files`)).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Back" })).json.error).toBe("deleted");
// Moderation removes one: no page, no files, out of libraries.
const bad = await make("Gone Shoes");
expect((await call(owner, "PUT", `/api/v1/library/${bad}`)).status).toBe(200);
expect((await call(maker, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${bad}`)).status).toBe(410);
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
});
+46 -7
View File
@@ -6,8 +6,9 @@ import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getMe, type Revision } from "@/lib/api";
import { libraryAction, retract } from "../actions";
import { Input } from "@/components/ui/input";
import { getMe, isModerator, type Revision } from "@/lib/api";
import { deleteProduct, libraryAction, removeProduct, retract } from "../actions";
import { loadProduct } from "../load";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
@@ -27,9 +28,16 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
<span className="flex flex-wrap items-center gap-2">
{product.creator && <span>by {product.creator}</span>}
<RatingTag rating={product.rating} />
{product.deleted && <Tag tone="danger">deleted</Tag>}
</span>
</PageHeader>
{product.deleted && (
<p className="text-sm text-muted-foreground">
{mine ? "You deleted this product." : "Its creator deleted this product."} People who have it keep it.
</p>
)}
{(product.image || product.categories.length > 0) && (
<div className="flex flex-wrap items-center gap-4">
{product.image && (
@@ -44,7 +52,7 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
)}
<div className="flex flex-wrap items-center gap-2">
{me ? (
{product.deleted && !inLibrary ? null : me ? (
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{inLibrary ? (
@@ -66,10 +74,21 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
Sign in to get it
</Link>
)}
{mine && (
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
Edit
</Link>
{mine && !product.deleted && (
<>
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
Edit
</Link>
<ActionForm action={deleteProduct} fields={{ id }}>
<SubmitButton
variant="outline"
pendingLabel="Deleting…"
confirm="Delete this product? Nobody new can get it. People who have it keep it."
>
Delete
</SubmitButton>
</ActionForm>
</>
)}
</div>
@@ -82,6 +101,26 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
</Card>
)}
{isModerator(me) && (
<Card>
<CardHeader>
<CardTitle>Moderation</CardTitle>
</CardHeader>
<CardContent>
<ActionForm action={removeProduct} fields={{ id }} className="flex flex-wrap items-center gap-2">
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
<SubmitButton
variant="destructive"
pendingLabel="Removing…"
confirm="Remove this product everywhere? Its page, files and library entries go."
>
Remove
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Revisions</CardTitle>
+24
View File
@@ -32,6 +32,30 @@ export async function libraryAction(_: ActionState, form: FormData): Promise<Act
});
}
/** Its creator deletes the product. */
export async function deleteProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).delete());
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** Moderation removes the product. */
export async function removeProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).remove.post({ reason: field(form, "reason") }));
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect("/products");
}
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
+2 -2
View File
@@ -2,13 +2,13 @@ import "server-only";
import { notFound } from "next/navigation";
import { api, ApiError, unwrap, type ProductPage } from "@/lib/api";
/** The product, or not found (also when it's adult and the visitor can't see adult products). */
/** The product, or not found (also when it's adult and the visitor can't see adult products, or it was removed). */
export async function loadProduct(id: string): Promise<ProductPage> {
if (!/^[a-z2-7]{10}$/.test(id)) notFound();
try {
return unwrap(await api.products({ id }).get());
} catch (e) {
if (e instanceof ApiError && e.status === 404) notFound();
if (e instanceof ApiError && (e.status === 404 || e.status === 410)) notFound();
throw e;
}
}
+2
View File
@@ -63,6 +63,8 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
| `GET /api/v1/products/:id` | (anyone, adult rule as above) `{product, revisions, inLibrary?, mine?}` |
| `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult |
| `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. 204 |
| `POST /api/v1/products/:id/remove` | (moderator) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, and it leaves libraries and rooms |
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products, each with the revision to use |
| `PUT /api/v1/library/:id` | get a product (add it) |
| `DELETE /api/v1/library/:id` | remove it |
@@ -0,0 +1,3 @@
ALTER TABLE "product" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "removed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "removed_reason" text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -57,6 +57,13 @@
"when": 1793364727935,
"tag": "0007_hosted_rooms_phase2",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1793372872749,
"tag": "0008_product_deletion",
"breakpoints": true
}
]
}
+5
View File
@@ -276,6 +276,11 @@ export const product = pgTable(
description: text("description").notNull().default(""), // HTML, already cleaned
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
latest: integer("latest").notNull().default(0), // the newest revision's number
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.
deletedAt: timestamp("deleted_at", { withTimezone: true }),
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
removedAt: timestamp("removed_at", { withTimezone: true }),
removedReason: text("removed_reason"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},