feat: choose a product's category from the ones T5DE has seen
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m8s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

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 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 06:00:04 -08:00
1 parent 8793b5afd8
commit ceceb69eaf
12 files changed
+165 -22

No files matched your search

+25 -3
View File
@@ -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 }),
}),
+47 -3
View File
@@ -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<string, { from: unknown; to: unknown }> = {};
@@ -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<CategoryPath[]> {
const parent = (await this.latestParent(p))?.parentInfo?.categories ?? [];
const seen = await this.db
.selectDistinct({ path: sql<CategoryPath>`${productRevision.parentInfo}->'categories'` })
.from(productRevision)
.where(sql`jsonb_array_length(coalesce(${productRevision.parentInfo}->'categories', '[]'::jsonb)) > 0`);
const byId = new Map<number, CategoryPath>();
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<string, unknown> = {};
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;
+22
View File
@@ -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 });
+41 -2
View File
@@ -23,6 +23,7 @@ const fieldNames: Record<string, string> = {
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") ? <LockedNote /> : <FieldDescription>Up to 10, separated by commas.</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="category">Category</FieldLabel>
{locked("category") || choices.length === 0 ? (
<p className="text-sm">{product.categories.length ? path(product.categories) : "None known"}</p>
) : (
<NativeSelect
id="category"
name="category"
defaultValue={String(product.category?.at(-1)?.id ?? "")}
className="w-full max-w-md"
>
<NativeSelectOption value="">
{parent.length ? `Its parent's: ${path(parent)}` : "None"}
</NativeSelectOption>
{choices
.filter((c) => c.at(-1)!.id !== parent.at(-1)?.id)
.map((c) => (
<NativeSelectOption key={c.at(-1)!.id} value={String(c.at(-1)!.id)}>
{path(c)}
</NativeSelectOption>
))}
</NativeSelect>
)}
{locked("category") ? (
<LockedNote />
) : (
<FieldDescription>
Where it&apos;s filed in the shop and inventory: any category T5DE has seen, within its parent&apos;s
kind of product.
</FieldDescription>
)}
</Field>
<div className="grid gap-6 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
@@ -180,7 +217,7 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
<CardContent>
<ActionForm action={saveLocks} fields={{ id }}>
<div className="flex flex-wrap gap-4">
{(["name", "description", "rating", "tags", "listed"] as const).map((f) => (
{(["name", "description", "rating", "tags", "listed", "category"] as const).map((f) => (
<label key={f} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
@@ -234,6 +271,8 @@ function LockedNote() {
/** A value as the history shows it. */
function shown(field: string, v: unknown): string {
if (field === "listed") return v ? "listed" : "unlisted";
if (field === "category")
return Array.isArray(v) ? v.map((c: { name?: string }) => c.name ?? "").join(" › ") : "its parent's";
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
if (typeof v === "string") return v || "empty";
return String(v);
+6 -3
View File
@@ -85,6 +85,7 @@ export async function saveProduct(_: ActionState, form: FormData): Promise<Actio
}
: {}),
...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}),
...(has("category") ? { category: Number(field(form, "category")) || null } : {}),
...(field(form, "reason") ? { reason: field(form, "reason") } : {}),
}),
);
@@ -110,9 +111,11 @@ export async function publishProduct(_: ActionState, form: FormData): Promise<Ac
/** Moderators lock fields: the checked ones are locked, the rest unlocked. */
export async function saveLocks(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const fields = form.getAll("locked").filter((v): v is "name" | "description" | "rating" | "tags" | "listed" =>
["name", "description", "rating", "tags", "listed"].includes(String(v)),
);
const fields = form
.getAll("locked")
.filter((v): v is "name" | "description" | "rating" | "tags" | "listed" | "category" =>
["name", "description", "rating", "tags", "listed", "category"].includes(String(v)),
);
return attempt(async () => {
unwrap(await api.products({ id }).locks.put({ fields, reason: field(form, "reason") }));
done(id);