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 <[email protected]>
This commit is contained in:
1 parent
8793b5afd8
commit
ceceb69eaf
12 files changed
+165
-22
No files matched your search
+25
-3
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user