feat: product drafts, moderator edits, info history, unlisted products, tags and categories (#8)
Phase 2 of the plan in `docs/design.md` ("Next"): product info.
**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.
**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.
**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.
**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).
**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.
**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.
**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.
**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.
**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase 2 of the plan in `docs/design.md` ("Next"): product info.
**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.
**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.
**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.
**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).
**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.
**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.
**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.
**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.
**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: dhkatz <[email protected]>
This commit was merged in pull request #8.
This commit is contained in:
1 parent
2cbedd26fc
commit
e07a6c6dac
24 files changed
+4248
-95
No files matched your search
@@ -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);
|
||||
@@ -266,6 +280,28 @@ test("derived products carry their IMVU parent's image and categories", async ()
|
||||
expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] });
|
||||
expect(await hub.products.fillParentInfo(50)).toBe(0);
|
||||
|
||||
// A product can be filed under another category the hub has seen, in its parent's top-level category: a jacket
|
||||
// made from a plain top goes under Coats & Jackets, never under Hair or Body (another kind of product).
|
||||
imvuProducts.set(7004, { ...coat, category_path: coat.category_path.slice(0, 3) });
|
||||
const jacketID = await upload("Dresser Jacket", 7004);
|
||||
for (let i = 0; i < 100 && (await page(jacketID)).categories.length === 0; i++) await Bun.sleep(20);
|
||||
const { choices, parent } = (await call(dresser, "GET", `/api/v1/products/${jacketID}/categories`)).json;
|
||||
expect(parent.map((c: any) => c.id)).toEqual([106, 40, 128]);
|
||||
expect(choices.map((c: any) => c.at(-1).id).sort()).toEqual([106, 128, 40, 716]);
|
||||
expect((await call(undefined, "GET", `/api/v1/products/${jacketID}/categories`)).status).toBe(401);
|
||||
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 75 })).json.error).toBe("invalid");
|
||||
const filed = await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 716 });
|
||||
expect(filed.json.fields).toEqual(["category"]);
|
||||
expect(filed.json.product.categories).toEqual(coat.category_path);
|
||||
expect(filed.json.product.category).toEqual(coat.category_path);
|
||||
expect(await names("&category=716")).toEqual(["Dresser Coat", "Dresser Jacket"]);
|
||||
// Back to its parent's: the history keeps paths, and undoing puts the chosen one back.
|
||||
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: null })).json.product.categories).toEqual(parent);
|
||||
expect(await names("&category=716")).toEqual(["Dresser Coat"]);
|
||||
const [cleared] = (await call(dresser, "GET", `/api/v1/products/${jacketID}/history`)).json.entries;
|
||||
expect(cleared.changes.category).toEqual({ from: coat.category_path, to: null });
|
||||
expect((await call(dresser, "POST", `/api/v1/products/${jacketID}/history/${cleared.id}/revert`, {})).json.product.categories).toEqual(coat.category_path);
|
||||
|
||||
// The library shows them too.
|
||||
expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200);
|
||||
expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image });
|
||||
@@ -287,6 +323,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 +350,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)] });
|
||||
});
|
||||
Reference in new issue
Block a user