feat: product drafts, moderator edits with reasons and locks, info history, unlisted products and tags
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m4s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're
deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and
publishes it.

Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the
creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings
(product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why,
and can be undone by the same rules.

Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and
catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do.

A product's own category is left for later: the hub has no list of IMVU's categories to pick from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 05:40:43 -08:00
1 parent 2cbedd26fc
commit 8793b5afd8
24 files changed
+4106 -96

No files matched your search

+1
View File
@@ -107,6 +107,7 @@ async function upload(maker: Person, c: { id: string; auth: Record<string, strin
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;
}
+120 -2
View File
@@ -122,10 +122,22 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401);
const made = await revision(up.json.token);
expect(made.status).toBe(201);
expect(made.json.revision).toBe(1);
expect(made.json).toMatchObject({ revision: 1, draft: true });
const id = made.json.product as string;
expect(id).toMatch(/^[a-z2-7]{10}$/);
// A new product is a draft: only its creator (and moderators) see it, and nobody can get it until it's published.
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).status).toBe(404);
expect((await call(kid, "GET", `/api/v1/products/${id}`)).status).toBe(404);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).status).toBe(404);
expect((await call(admin, "GET", `/api/v1/products/${id}`)).json.product.status).toBe("draft");
expect((await call(maker, "GET", `/api/v1/products/${id}`)).json.product).toMatchObject({ status: "draft", publishedAt: null });
expect((await call(maker, "PUT", `/api/v1/library/${id}`)).json.error).toBe("draft");
expect((await call(maker, "GET", "/api/v1/products?creator=maker")).json.products).toEqual([]);
expect((await call(kid, "POST", `/api/v1/products/${id}/publish`)).status).toBe(403);
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.product.status).toBe("published");
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.error).toBe("published");
// Its page: info, revisions (files sorted), hosting.
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" });
@@ -147,7 +159,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect((await revision(up2)).status).toBe(400);
expect((await revision(up2, { notes: "same" })).status).toBe(409);
const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] });
expect(r2.json).toEqual({ product: id, revision: 2 });
expect(r2.json).toEqual({ product: id, revision: 2, draft: false });
// Someone else's upload token can't add to it.
expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403);
@@ -174,6 +186,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
// Adult products are only for adults.
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product;
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);
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
@@ -230,6 +243,7 @@ test("derived products carry their IMVU parent's image and categories", async ()
const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth);
expect(r.status).toBe(201);
await call(dresser, "POST", `/api/v1/products/${r.json.product}/publish`);
return r.json.product as string;
};
const coatID = await upload("Dresser Coat", 7001);
@@ -287,6 +301,7 @@ test("creators delete products (those who have them keep them); moderation remov
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);
await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`);
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);
@@ -313,3 +328,106 @@ test("creators delete products (those who have them keep them); moderation remov
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]);
});
test("product info: moderators edit with reasons and lock fields; history and reverts; unlisted and tags", async () => {
const { admin, catalogID, auth } = shared;
const maker = await person("tailor", { id: 521, ageVerified: true });
const other = await person("browser", { id: 522, ageVerified: true });
const mod = await person("warden", { id: 523, ageVerified: true });
const modID = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${modID}/role`, { role: "moderator" })).status).toBe(200);
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const id = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("vest.xml", 7)], rating: "general", parent: 0, name: "Vest", notes: "" }, auth)).json.product as string;
// The creator sets it up while it's a draft: tags (cleaned), unlisted; then publishes.
const set = await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: [" Formal ", "formal", "Wool Blend"], listed: false });
expect(set.json.fields).toEqual(["tags", "listed"]);
expect(set.json.product).toMatchObject({ tags: ["formal", "wool blend"], listed: false });
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: ["<b>"] })).json.error).toBe("invalid");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: false })).json.fields).toEqual([]);
await call(maker, "POST", `/api/v1/products/${id}/publish`);
// Unlisted: not in lists or search for others (its creator still sees it); its link works, and people can get it.
const names = async (p: Person | undefined, qs: string) => (await call(p, "GET", `/api/v1/products?${qs}`)).json.products.map((x: any) => x.name);
expect(await names(other, "creator=tailor")).toEqual([]);
expect(await names(maker, "creator=tailor")).toEqual(["Vest"]);
expect((await call(other, "GET", `/api/v1/products/${id}`)).status).toBe(200);
expect((await call(other, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: true });
expect(await names(other, "tag=formal")).toEqual(["Vest"]);
expect(await names(other, "tag=Wool%20Blend")).toEqual(["Vest"]);
expect(await names(other, "tag=denim")).toEqual([]);
expect(await names(other, `creator=tailor&catalog=${catalogID}`)).toEqual(["Vest"]);
// Others can't edit; a moderator can, with a reason the creator sees.
expect((await call(other, "PATCH", `/api/v1/products/${id}`, { name: "Mine" })).json).toMatchObject({
error: "forbidden",
missing: { permission: "product:edit" },
});
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest" })).json.error).toBe("reason_needed");
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest", reason: "Misleading name" })).status).toBe(200);
// Locks: the creator can't change a locked field; moderators can. The page lists what the person may do.
expect((await call(maker, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "x" })).status).toBe(403);
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "" })).json.error).toBe("reason_needed");
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "Keeps renaming it" })).json.product.locked).toEqual(["name"]);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Vest!" })).json.error).toBe("locked");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { description: "<p>Warm</p>" })).status).toBe(200);
const page = (await call(maker, "GET", `/api/v1/products/${id}`)).json;
expect(page.product.locked).toEqual(["name"]);
expect(page.permissions).toContain("product:edit");
expect(page.permissions).not.toContain("product:edit-locked");
expect((await call(mod, "GET", `/api/v1/products/${id}`)).json.permissions).toContain("product:edit-locked");
// 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(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.
expect((await call(other, "GET", `/api/v1/products/${id}/history`)).status).toBe(403);
const history = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries;
expect(history.map((e: any) => [e.kind, Object.keys(e.changes).join(), e.by, e.byCreator])).toEqual([
["edit", "rating", "warden", false],
["edit", "rating", "tailor", true],
["edit", "description", "tailor", true],
["lock", "locked", "warden", false],
["edit", "name", "warden", false],
["edit", "listed", "tailor", true],
["publish", "status", "tailor", true],
["edit", "tags,listed", "tailor", true],
]);
expect(history[4]).toMatchObject({ reason: "Misleading name", changes: { name: { from: "Vest", to: "Wool Vest" } } });
// Reverting puts back what an entry changed, by the same rules: the name is locked for the creator.
const rename = history[4].id;
expect((await call(maker, "POST", `/api/v1/products/${id}/history/${rename}/revert`, {})).json.error).toBe("locked");
const reverted = await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "My mistake" });
expect(reverted.json.product.name).toBe("Vest");
expect((await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "Again" })).json.error).toBe("unchanged");
const latest = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries[0];
expect(latest).toMatchObject({ kind: "revert", revertOf: rename, reason: "My mistake" });
// Moderators' edits are in the creator's audit log, tagged with the product.
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${id}`)).json.entries;
expect(audit.map((e: any) => e.action)).toContain("product.lock");
// The console's product list: the creator's own (drafts too); moderators everyone's, filtered.
const draftToken = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const draft = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: draftToken, files: [file("cap.xml", 8)], rating: "general", parent: 0, name: "Cap", notes: "" }, auth)).json.product as string;
const mine = (await call(maker, "GET", "/api/v1/console/products")).json;
expect(mine.products.map((p: any) => [p.name, p.status, p.libraries])).toEqual([["Cap", "draft", 0], ["Vest", "published", 1]]);
expect((await call(maker, "GET", "/api/v1/console")).json.todo.drafts).toBe(1);
expect((await call(other, "GET", "/api/v1/console/products")).status).toBe(403);
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=locked")).json.products.map((p: any) => p.id)).toEqual([id]);
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=draft&q=tailor")).json.products.map((p: any) => p.id)).toEqual([draft]);
// Drafts never published go after 30 days, and their catalogs are told to delete the files.
expect(await hub.products.purgeDrafts(new Date(Date.now() + 29 * 24 * 60 * 60 * 1000))).toBe(0);
expect(await hub.products.purgeDrafts(new Date(Date.now() + 31 * 24 * 60 * 60 * 1000))).toBeGreaterThanOrEqual(1);
expect((await call(maker, "GET", `/api/v1/products/${draft}`)).status).toBe(404);
expect((await call(maker, "GET", `/api/v1/products/${id}`)).status).toBe(200);
const changes = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, auth)).json.changes;
expect(changes.find((c: any) => c.product === draft)).toMatchObject({ type: "delete", revision: 1, files: [file("cap.xml", 8)] });
});