From ceceb69eaf200432fba01b5f800992a0fd613648 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 5 Nov 2026 06:00:04 -0800 Subject: [PATCH] feat: choose a product's category from the ones T5DE has seen A product can be filed under another IMVU category than its parent's: any category path the hub has seen on a revision's parent (and each step of it), within the parent's top-level category, so it stays the same kind of product (the proxy tells clothing, furniture and rooms apart by category). It's an info field like the others: lockable, in the history, undone by the same rules. Lists filter by the chosen path, and products show it. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 28 ++++++++++-- apps/api/src/products.ts | 50 +++++++++++++++++++-- apps/api/test/products.test.ts | 22 +++++++++ apps/web/app/products/[id]/edit/page.tsx | 43 +++++++++++++++++- apps/web/app/products/actions.ts | 9 ++-- docs/api.md | 5 ++- docs/design.md | 5 ++- docs/products.md | 12 ++--- packages/db/drizzle/0011_product_info.sql | 1 + packages/db/drizzle/meta/0011_snapshot.json | 8 +++- packages/db/drizzle/meta/_journal.json | 2 +- packages/db/src/schema.ts | 2 + 12 files changed, 165 insertions(+), 22 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a17fce5..6c00307 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -332,7 +332,9 @@ export function createApp({ creatorUsername: creatorUsername ?? "", parent: latest?.parent ?? 0, image: latest?.parentInfo?.image ?? null, - categories: latest?.parentInfo?.categories ?? [], + // Its own category if one was chosen, else its parent's. + categories: p.category ?? latest?.parentInfo?.categories ?? [], + category: p.category, status: p.status as "draft" | "published", listed: p.listed, tags: p.tags, @@ -1704,10 +1706,23 @@ export function createApp({ rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])), tags: t.Optional(t.Array(t.String({ maxLength: 64 }), { maxItems: 20 })), listed: t.Optional(t.Boolean()), + category: t.Optional(t.Union([t.Integer({ minimum: 1 }), t.Null()])), reason: t.Optional(t.String({ maxLength: 500 })), }), }, ) + // The categories a product may be filed under. + .get( + "/products/:id/categories", + async ({ me, params }) => { + await creatorOf(params.id); + await need(me, "product:edit", productScope(params.id)); + const [p] = await db.select().from(product).where(eq(product.id, params.id)); + const parent = (await products.latestParent(p!))?.parentInfo?.categories ?? []; + return { choices: await products.categoryChoices(p!), parent }; + }, + { signedIn: true, params: productParam }, + ) // A draft becomes published. .post( "/products/:id/publish", @@ -1787,8 +1802,15 @@ export function createApp({ params: productParam, body: t.Object({ fields: t.Array( - t.Union([t.Literal("name"), t.Literal("description"), t.Literal("rating"), t.Literal("tags"), t.Literal("listed")]), - { maxItems: 5 }, + t.Union([ + t.Literal("name"), + t.Literal("description"), + t.Literal("rating"), + t.Literal("tags"), + t.Literal("listed"), + t.Literal("category"), + ]), + { maxItems: 6 }, ), reason: t.String({ maxLength: 500 }), }), diff --git a/apps/api/src/products.ts b/apps/api/src/products.ts index 26e034a..e093191 100644 --- a/apps/api/src/products.ts +++ b/apps/api/src/products.ts @@ -28,9 +28,20 @@ export type Product = typeof product.$inferSelect; export type Revision = typeof productRevision.$inferSelect; /** Info fields people edit (and moderators lock). */ -export const infoFields = ["name", "description", "rating", "tags", "listed"] as const; +export const infoFields = ["name", "description", "rating", "tags", "listed", "category"] as const; export type InfoField = (typeof infoFields)[number]; -export type InfoInput = { name?: string; description?: string; rating?: string; tags?: string[]; listed?: boolean }; +/** `category`: an IMVU category ID from categoryChoices, or null for its parent's. */ +export type InfoInput = { + name?: string; + description?: string; + rating?: string; + tags?: string[]; + listed?: boolean; + category?: number | null; +}; + +/** An IMVU category path, outermost first. */ +export type CategoryPath = { id: number; name: string }[]; /** What the person editing may do beyond product:edit (from the permission check). */ export type EditRights = { creator: boolean; editLocked: boolean; rateLower: boolean }; @@ -407,7 +418,9 @@ export class Products { 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())); if (opts.category !== undefined) - conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`); + conds.push( + sql`coalesce(${product.category}, ${productRevision.parentInfo}->'categories') @> ${JSON.stringify([{ id: opts.category }])}::jsonb`, + ); const tag = opts.tag !== undefined ? checkTags([opts.tag])?.[0] : undefined; if (opts.tag !== undefined) conds.push(tag ? sql`${product.tags} @> ${JSON.stringify([tag])}::jsonb` : sql`false`); if (opts.catalog) @@ -513,6 +526,15 @@ export class Products { next.tags = tags; } if (input.listed !== undefined) next.listed = input.listed; + if (input.category !== undefined) { + if (input.category === null) next.category = null; + else { + const path = (await this.categoryChoices(p)).find((c) => c.at(-1)!.id === input.category); + if (!path) + throw new ProductError(400, "invalid", "Choose one of the categories offered for this product."); + next.category = path; + } + } const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); const changes: Record = {}; @@ -582,6 +604,26 @@ export class Products { }); } + /** + * The categories a product may be filed under (docs/products.md): every IMVU category path the hub has seen on a + * revision's parent, and each step of those paths, under the same top-level category as its own parent's (any, if + * its parent's is unknown). Keeping the top level keeps it the same kind of product (clothing, furniture, rooms). + */ + async categoryChoices(p: Product): Promise { + const parent = (await this.latestParent(p))?.parentInfo?.categories ?? []; + const seen = await this.db + .selectDistinct({ path: sql`${productRevision.parentInfo}->'categories'` }) + .from(productRevision) + .where(sql`jsonb_array_length(coalesce(${productRevision.parentInfo}->'categories', '[]'::jsonb)) > 0`); + const byId = new Map(); + for (const path of [parent, ...seen.map((s) => s.path)]) + for (let i = 0; i < path.length; i++) + if (!byId.has(path[i]!.id)) byId.set(path[i]!.id, path.slice(0, i + 1).map((c) => ({ id: c.id, name: c.name }))); + const top = parent[0]?.id; + const label = (c: CategoryPath) => c.map((x) => x.name).join(" / "); + return [...byId.values()].filter((c) => top === undefined || c[0]!.id === top).sort((a, b) => label(a).localeCompare(label(b))); + } + /** A product's info history, newest first, with who made each change. */ async history(id: string) { return this.db @@ -610,6 +652,8 @@ export class Products { if (!e || (e.kind !== "edit" && e.kind !== "revert")) throw new ProductError(404, "not_found", "No such change to undo."); const input: Record = {}; for (const f of infoFields) if (e.changes[f]) input[f] = e.changes[f].from; + // Categories are kept as paths; an edit takes the category's ID. + if (Array.isArray(input.category)) input.category = (input.category as CategoryPath).at(-1)?.id ?? null; const done = await this.updateInfo(byId, id, input as InfoInput, reason, rights, e.id); if (done.fields.length === 0) throw new ProductError(409, "unchanged", "Its info is already as it was before that change."); return done; diff --git a/apps/api/test/products.test.ts b/apps/api/test/products.test.ts index edd2596..edf27e6 100644 --- a/apps/api/test/products.test.ts +++ b/apps/api/test/products.test.ts @@ -280,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 }); diff --git a/apps/web/app/products/[id]/edit/page.tsx b/apps/web/app/products/[id]/edit/page.tsx index 0b127a6..00d42e1 100644 --- a/apps/web/app/products/[id]/edit/page.tsx +++ b/apps/web/app/products/[id]/edit/page.tsx @@ -23,6 +23,7 @@ const fieldNames: Record = { rating: "Rating", tags: "Tags", listed: "Listed", + category: "Category", locked: "Locks", status: "Status", }; @@ -35,7 +36,11 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st const can = (p: string) => (permissions as string[]).includes(p); if (!can("product:edit")) notFound(); if (mine && product.deleted) redirect(`/products/${id}`); - const { entries } = unwrap(await api.products({ id }).history.get()); + const [{ entries }, { choices, parent }] = await Promise.all([ + api.products({ id }).history.get().then(unwrap), + api.products({ id }).categories.get().then(unwrap), + ]); + const path = (c: { name: string }[]) => c.map((x) => x.name).join(" › "); const editLocked = can("product:edit-locked"); const locked = (f: string) => product.locked.includes(f) && !editLocked; @@ -122,6 +127,38 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st /> {locked("tags") ? : Up to 10, separated by commas.} + + Category + {locked("category") || choices.length === 0 ? ( +

{product.categories.length ? path(product.categories) : "None known"}

+ ) : ( + + + {parent.length ? `Its parent's: ${path(parent)}` : "None"} + + {choices + .filter((c) => c.at(-1)!.id !== parent.at(-1)?.id) + .map((c) => ( + + {path(c)} + + ))} + + )} + {locked("category") ? ( + + ) : ( + + Where it's filed in the shop and inventory: any category T5DE has seen, within its parent's + kind of product. + + )} +
Rating @@ -180,7 +217,7 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
- {(["name", "description", "rating", "tags", "listed"] as const).map((f) => ( + {(["name", "description", "rating", "tags", "listed", "category"] as const).map((f) => (