feat: product drafts, moderator edits, info history, unlisted products, tags and categories #8

Merged
dhkatz merged 2 commits from feat/product-info into main 2026-11-05 14:23:27 +00:00
12 changed files with 164 additions and 21 deletions
Showing only changes of commit ceceb69eaf - Show all commits

No files matched your search

+25 -3
View File
@@ -332,7 +332,9 @@ export function createApp({
creatorUsername: creatorUsername ?? "", creatorUsername: creatorUsername ?? "",
parent: latest?.parent ?? 0, parent: latest?.parent ?? 0,
image: latest?.parentInfo?.image ?? null, 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", status: p.status as "draft" | "published",
listed: p.listed, listed: p.listed,
tags: p.tags, tags: p.tags,
@@ -1704,10 +1706,23 @@ export function createApp({
rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])), rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])),
tags: t.Optional(t.Array(t.String({ maxLength: 64 }), { maxItems: 20 })), tags: t.Optional(t.Array(t.String({ maxLength: 64 }), { maxItems: 20 })),
listed: t.Optional(t.Boolean()), listed: t.Optional(t.Boolean()),
category: t.Optional(t.Union([t.Integer({ minimum: 1 }), t.Null()])),
reason: t.Optional(t.String({ maxLength: 500 })), 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. // A draft becomes published.
.post( .post(
"/products/:id/publish", "/products/:id/publish",
@@ -1787,8 +1802,15 @@ export function createApp({
params: productParam, params: productParam,
body: t.Object({ body: t.Object({
fields: t.Array( fields: t.Array(
t.Union([t.Literal("name"), t.Literal("description"), t.Literal("rating"), t.Literal("tags"), t.Literal("listed")]), t.Union([
{ maxItems: 5 }, 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 }), 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; export type Revision = typeof productRevision.$inferSelect;
/** Info fields people edit (and moderators lock). */ /** 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 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). */ /** What the person editing may do beyond product:edit (from the permission check). */
export type EditRights = { creator: boolean; editLocked: boolean; rateLower: boolean }; 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.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.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
if (opts.category !== undefined) 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; 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.tag !== undefined) conds.push(tag ? sql`${product.tags} @> ${JSON.stringify([tag])}::jsonb` : sql`false`);
if (opts.catalog) if (opts.catalog)
@@ -513,6 +526,15 @@ export class Products {
next.tags = tags; next.tags = tags;
} }
if (input.listed !== undefined) next.listed = input.listed; 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 same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const changes: Record<string, { from: unknown; to: unknown }> = {}; 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. */ /** A product's info history, newest first, with who made each change. */
async history(id: string) { async history(id: string) {
return this.db 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."); if (!e || (e.kind !== "edit" && e.kind !== "revert")) throw new ProductError(404, "not_found", "No such change to undo.");
const input: Record<string, unknown> = {}; const input: Record<string, unknown> = {};
for (const f of infoFields) if (e.changes[f]) input[f] = e.changes[f].from; 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); 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."); if (done.fields.length === 0) throw new ProductError(409, "unchanged", "Its info is already as it was before that change.");
return done; 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 page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] });
expect(await hub.products.fillParentInfo(50)).toBe(0); 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. // The library shows them too.
expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200); 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 }); 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", rating: "Rating",
tags: "Tags", tags: "Tags",
listed: "Listed", listed: "Listed",
category: "Category",
locked: "Locks", locked: "Locks",
status: "Status", 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); const can = (p: string) => (permissions as string[]).includes(p);
if (!can("product:edit")) notFound(); if (!can("product:edit")) notFound();
if (mine && product.deleted) redirect(`/products/${id}`); 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 editLocked = can("product:edit-locked");
const locked = (f: string) => product.locked.includes(f) && !editLocked; 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>} {locked("tags") ? <LockedNote /> : <FieldDescription>Up to 10, separated by commas.</FieldDescription>}
</Field> </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"> <div className="grid gap-6 sm:grid-cols-2">
<Field> <Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel> <FieldLabel htmlFor="rating">Rating</FieldLabel>
@@ -180,7 +217,7 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
<CardContent> <CardContent>
<ActionForm action={saveLocks} fields={{ id }}> <ActionForm action={saveLocks} fields={{ id }}>
<div className="flex flex-wrap gap-4"> <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"> <label key={f} className="flex items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
@@ -234,6 +271,8 @@ function LockedNote() {
/** A value as the history shows it. */ /** A value as the history shows it. */
function shown(field: string, v: unknown): string { function shown(field: string, v: unknown): string {
if (field === "listed") return v ? "listed" : "unlisted"; 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 (Array.isArray(v)) return v.length ? v.join(", ") : "none";
if (typeof v === "string") return v || "empty"; if (typeof v === "string") return v || "empty";
return String(v); return String(v);
+5 -2
View File
@@ -85,6 +85,7 @@ export async function saveProduct(_: ActionState, form: FormData): Promise<Actio
} }
: {}), : {}),
...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}), ...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}),
...(has("category") ? { category: Number(field(form, "category")) || null } : {}),
...(field(form, "reason") ? { reason: field(form, "reason") } : {}), ...(field(form, "reason") ? { reason: field(form, "reason") } : {}),
}), }),
); );
@@ -110,8 +111,10 @@ export async function publishProduct(_: ActionState, form: FormData): Promise<Ac
/** Moderators lock fields: the checked ones are locked, the rest unlocked. */ /** Moderators lock fields: the checked ones are locked, the rest unlocked. */
export async function saveLocks(_: ActionState, form: FormData): Promise<ActionState> { export async function saveLocks(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id"); const id = field(form, "id");
const fields = form.getAll("locked").filter((v): v is "name" | "description" | "rating" | "tags" | "listed" => const fields = form
["name", "description", "rating", "tags", "listed"].includes(String(v)), .getAll("locked")
.filter((v): v is "name" | "description" | "rating" | "tags" | "listed" | "category" =>
["name", "description", "rating", "tags", "listed", "category"].includes(String(v)),
); );
return attempt(async () => { return attempt(async () => {
unwrap(await api.products({ id }).locks.put({ fields, reason: field(form, "reason") })); unwrap(await api.products({ id }).locks.put({ fields, reason: field(form, "reason") }));
+3 -2
View File
@@ -87,8 +87,9 @@ sha256 (hex) of `contents` as JSON.
## Products, catalogs and libraries ## Products, catalogs and libraries
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator, The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
creatorUsername, parent, image, categories, status, listed, tags, locked, deleted, removed, publishedAt, createdAt, creatorUsername, parent, image, categories, category, status, listed, tags, locked, deleted, removed, publishedAt,
updatedAt}`; `parent`, `image` and `categories` come from the latest revision (its IMVU parent's public info). createdAt, updatedAt}`; `parent` and `image` come from the latest revision (its IMVU parent's public info), and
`categories` too unless a category was chosen for it (`category`).
`GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU category ID), `tag`, `catalog` and `limit` (1-200, `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU category ID), `tag`, `catalog` and `limit` (1-200,
default 60). default 60).
+3 -2
View File
@@ -473,8 +473,9 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md):
**Done** (2026-11-05), except catalog deletion requests and the hosted rooms' scope, which come with cases **Done** (2026-11-05), except catalog deletion requests and the hosted rooms' scope, which come with cases
(phase 3). Built-in roles are code (`apps/api/src/permissions.ts`); the `role` table comes with custom roles. (phase 3). Built-in roles are code (`apps/api/src/permissions.ts`); the `role` table comes with custom roles.
2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category. 2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category.
**Done** (2026-11-05), except a product's own category: the hub has no list of IMVU's categories to choose from, **Done** (2026-11-05). A product's category is chosen from the IMVU category paths the hub has seen on parents,
so products are filed under their parent's category path for now (later: IMVU's category tree, cached). within its parent's top-level category; IMVU's own category tree (if it has a public one) would replace those
(to look into with the importer spike).
3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens; 3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens;
two-factor sign-in for sensitive permissions. two-factor sign-in for sensitive permissions.
4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike). 4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike).
+7 -5
View File
@@ -107,13 +107,14 @@ catalog's). Only approved catalogs with imports on and not paused.
| `DELETE /api/v1/catalogs/:id/products/:product` | (`hosting:delist`) `{reason}`: the catalog stops hosting every revision of it; it's in the changes feed. The product stays available from other catalogs hosting it. | | `DELETE /api/v1/catalogs/:id/products/:product` | (`hosting:delist`) `{reason}`: the catalog stops hosting every revision of it; it's in the changes feed. The product stays available from other catalogs hosting it. |
| `POST /api/v1/catalogs/:id/suspend`, `.../reinstate` | (`catalog:suspend`) `{reason}`: a suspended catalog's products aren't available from it, it can't register revisions, and its team sees why | | `POST /api/v1/catalogs/:id/suspend`, `.../reinstate` | (`catalog:suspend`) `{reason}`: a suspended catalog's products aren't available from it, it can't register revisions, and its team sees why |
| `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL (the catalog's upload policy and rating ceiling apply) | | `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL (the catalog's upload policy and rating ceiling apply) |
| `GET /api/v1/products?q=&creator=&category=&catalog=&tag=&limit=` | (anyone) `{products}`, newest first: published and listed (and the person's own unlisted ones); adult ones only for people with an adult account. `category`: an IMVU category ID in its latest revision's parent's category path (e.g. 106 Clothing, 128 Tops). `tag`: one tag; `catalog`: those the catalog hosts. `limit`: 1-200, default 60 | | `GET /api/v1/products?q=&creator=&category=&catalog=&tag=&limit=` | (anyone) `{products}`, newest first: published and listed (and the person's own unlisted ones); adult ones only for people with an adult account. `category`: an IMVU category ID in its category path (its own if chosen, else its latest revision's parent's; e.g. 106 Clothing, 128 Tops). `tag`: one tag; `catalog`: those the catalog hosts. `limit`: 1-200, default 60 |
| `GET /api/v1/products/:id` | (anyone, adult rule as above; drafts only for their creator and moderators) `{product, revisions, inLibrary?, mine?, available, permissions}` | | `GET /api/v1/products/:id` | (anyone, adult rule as above; drafts only for their creator and moderators) `{product, revisions, inLibrary?, mine?, available, permissions}` |
| `PATCH /api/v1/products/:id` | (`product:edit`) `{name?, description?, rating?, tags?, listed?, reason?}` → `{product, fields}` (the fields that changed; only those go in the history). Description is HTML, cleaned by the allowlist. Tags: up to 10, lowercased, each up to 24 letters, digits, spaces or dashes. Rating may go general → adult; adult → general needs `product:rate-lower` (400 `review_needed`; with cases it opens a `rating-review` case instead), and no revision that isn't retracted may be rated adult (400 `files_adult`). `reason` is required when the person isn't the creator (400 `reason_needed`), and shown to them. Locked fields need `product:edit-locked` (403 `locked`). The creator can't edit a product they deleted; moderators can. | | `PATCH /api/v1/products/:id` | (`product:edit`) `{name?, description?, rating?, tags?, listed?, category?, reason?}` → `{product, fields}` (the fields that changed; only those go in the history). Description is HTML, cleaned by the allowlist. Tags: up to 10, lowercased, each up to 24 letters, digits, spaces or dashes. `category`: the ID of one of its category choices (below), or null for its parent's. Rating may go general → adult; adult → general needs `product:rate-lower` (400 `review_needed`; with cases it opens a `rating-review` case instead), and no revision that isn't retracted may be rated adult (400 `files_adult`). `reason` is required when the person isn't the creator (400 `reason_needed`), and shown to them. Locked fields need `product:edit-locked` (403 `locked`). The creator can't edit a product they deleted; moderators can. |
| `GET /api/v1/products/:id/categories` | (`product:edit`) `{choices, parent}`: the category paths it may be filed under, and its parent's. The choices are every IMVU category path the hub has seen on a revision's parent, and each step of them, under the same top-level category as its parent's (any, if that's unknown). Keeping the top level keeps it the same kind of product: the proxy tells clothing, furniture and rooms apart by category. IMVU's own category tree would replace the seen paths if one turns up. |
| `POST /api/v1/products/:id/publish` | (`product:publish`) a draft becomes published (409 `published` if it is already) | | `POST /api/v1/products/:id/publish` | (`product:publish`) a draft becomes published (409 `published` if it is already) |
| `GET /api/v1/products/:id/history` | (`audit:read` at the product: its creator, moderators) the info history, newest first: `{entries: [{id, at, kind ("edit", "revert", "lock", "publish"), by, byCreator, changes: {field: {from, to}}, reason, revertOf}]}` | | `GET /api/v1/products/:id/history` | (`audit:read` at the product: its creator, moderators) the info history, newest first: `{entries: [{id, at, kind ("edit", "revert", "lock", "publish"), by, byCreator, changes: {field: {from, to}}, reason, revertOf}]}` |
| `POST /api/v1/products/:id/history/:entry/revert` | (`product:edit`) `{reason?}`: puts back what an edit (or revert) changed, as a new `revert` entry, by the same rules as an edit (locks, reasons, ratings); 409 `unchanged` if the info is already as it was | | `POST /api/v1/products/:id/history/:entry/revert` | (`product:edit`) `{reason?}`: puts back what an edit (or revert) changed, as a new `revert` entry, by the same rules as an edit (locks, reasons, ratings); 409 `unchanged` if the info is already as it was |
| `PUT /api/v1/products/:id/locks` | (`product:lock`) `{fields, reason}`: the fields locked from now on (any of name, description, rating, tags, listed; the rest are unlocked). `reason` required; kept in the history | | `PUT /api/v1/products/:id/locks` | (`product:lock`) `{fields, reason}`: the fields locked from now on (any of name, description, rating, tags, listed, category; the rest are unlocked). `reason` required; kept in the history |
| `POST /api/v1/products/:id/transfer` | (`product:transfer`) `{to}`; the other person accepts (`.../transfer/accept`) | | `POST /api/v1/products/:id/transfer` | (`product:transfer`) `{to}`; the other person accepts (`.../transfer/accept`) |
| `POST /api/v1/products/:id/restore` | (`product:restore`) undo a creator's delete | | `POST /api/v1/products/:id/restore` | (`product:restore`) undo a creator's delete |
| `POST /api/v1/products/:id/hosting` | (`hosting:choose`) `{catalog, revision?}`: ask another catalog to mirror it (every revision, or one); the catalog's `mirrors` setting decides (auto, or its team approves) | | `POST /api/v1/products/:id/hosting` | (`hosting:choose`) `{catalog, revision?}`: ask another catalog to mirror it (every revision, or one); the catalog's `mirrors` setting decides (auto, or its team approves) |
@@ -131,9 +132,10 @@ catalog's). Only approved catalogs with imports on and not paused.
The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes. The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes.
A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername, A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername,
parent, image, categories, status ("draft"/"published"), listed, tags, locked, deleted, removed, publishedAt, parent, image, categories, category, status ("draft"/"published"), listed, tags, locked, deleted, removed, publishedAt,
imvuProduct, createdAt, updatedAt}` (`imvuProduct` comes with imports). The product's page also has `permissions`: what the imvuProduct, createdAt, updatedAt}` (`imvuProduct` comes with imports). The product's page also has `permissions`: what the
person may do to it (the website shows actions by them). `parent`, `image` and `categories` are the latest revision's: person may do to it (the website shows actions by them). `category` is the path chosen for it (null: its parent's), and `categories` the one it's filed under (`category` if
chosen); `parent`, `image` and the parent's categories are the latest revision's:
`parent` is the IMVU product it derives from (0 if none); `image` (an https address on `*.imvu.com`, or null) and `parent` is the IMVU product it derives from (0 if none); `image` (an https address on `*.imvu.com`, or null) and
`categories` (`[{id, name}]`, outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; `[]` if `categories` (`[{id, name}]`, outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; `[]` if
unknown) are that IMVU product's, from IMVU's public API (`GET https://api.imvu.com/product/product-<id>`). unknown) are that IMVU product's, from IMVU's public API (`GET https://api.imvu.com/product/product-<id>`).
@@ -13,6 +13,7 @@ ALTER TABLE "product" ADD COLUMN "status" text DEFAULT 'published' NOT NULL;-->
ALTER TABLE "product" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint ALTER TABLE "product" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "listed" boolean DEFAULT true NOT NULL;--> statement-breakpoint ALTER TABLE "product" ADD COLUMN "listed" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "tags" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint ALTER TABLE "product" ADD COLUMN "tags" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "category" jsonb;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "locked" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint ALTER TABLE "product" ADD COLUMN "locked" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+7 -1
View File
@@ -1,5 +1,5 @@
{ {
"id": "5663cafb-98ed-4a05-8163-f86b813ea0a3", "id": "0b410a02-49ab-42cc-82e6-45b3bd265602",
"prevId": "2a4756bc-d197-4321-84ef-a8238ed002d3", "prevId": "2a4756bc-d197-4321-84ef-a8238ed002d3",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -1938,6 +1938,12 @@
"notNull": true, "notNull": true,
"default": "'[]'::jsonb" "default": "'[]'::jsonb"
}, },
"category": {
"name": "category",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"locked": { "locked": {
"name": "locked", "name": "locked",
"type": "jsonb", "type": "jsonb",
+1 -1
View File
@@ -82,7 +82,7 @@
{ {
"idx": 11, "idx": 11,
"version": "7", "version": "7",
"when": 1793884711638, "when": 1793886718951,
"tag": "0011_product_info", "tag": "0011_product_info",
"breakpoints": true "breakpoints": true
} }
+2
View File
@@ -365,6 +365,8 @@ export const product = pgTable(
// Unlisted: left out of search, lists and creator pages for others; its link still works. // Unlisted: left out of search, lists and creator pages for others; its link still works.
listed: boolean("listed").notNull().default(true), listed: boolean("listed").notNull().default(true),
tags: jsonb("tags").$type<string[]>().notNull().default([]), tags: jsonb("tags").$type<string[]>().notNull().default([]),
// The IMVU category path its creator (or a moderator) chose, outermost first; null: its parent's.
category: jsonb("category").$type<{ id: number; name: string }[]>(),
// Info fields a moderator locked: only people with product:edit-locked change them. // Info fields a moderator locked: only people with product:edit-locked change them.
locked: jsonb("locked").$type<string[]>().notNull().default([]), locked: jsonb("locked").$type<string[]>().notNull().default([]),
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it. // Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.