feat: product drafts, moderator edits, info history, unlisted products, tags and categories #8
No files matched your search
+25
-3
@@ -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 }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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's filed in the shop and inventory: any category T5DE has seen, within its parent'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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user