feat: product drafts, moderator edits, info history, unlisted products, tags and categories (#8)
Release / release (push) Successful in 13s
CI / test (push) Successful in 1m5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 4s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 16s

Phase 2 of the plan in `docs/design.md` ("Next"): product info.

**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.

**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.

**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.

**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).

**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.

**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.

**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.

**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.

**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 2 of the plan in `docs/design.md` ("Next"): product info.

**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.

**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.

**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.

**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).

**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.

**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.

**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.

**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.

**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
This commit was merged in pull request #8.
This commit is contained in:
dhkatz authored and dhkatz committed 2026-11-05 14:23:26 +00:00
1 parent 2cbedd26fc
commit e07a6c6dac
24 files changed
+4248 -95

No files matched your search

+190 -9
View File
@@ -26,7 +26,7 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { availableTo, ProductError, Products } from "./products";
import { availableTo, ProductError, Products, type EditRights, type ProductViewer } from "./products";
import {
chatId,
HostedError,
@@ -332,12 +332,38 @@ 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,
locked: p.locked,
deleted: p.deletedAt !== null,
removed: p.removedAt !== null,
publishedAt: p.publishedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
});
const productViewer = (p: Person | undefined): ProductViewer | undefined =>
p ? { id: p.id, moderator: isModerator(p) } : undefined;
const productScope = (id: string): Scope => ({ type: "product", id });
/** What someone editing a product may do beyond product:edit. */
const editRights = async (me: Person, id: string, creatorId: string): Promise<EditRights> => ({
creator: me.id === creatorId,
editLocked: await perms.can(me, "product:edit-locked", productScope(id)),
rateLower: await perms.can(me, "product:rate-lower", productScope(id)),
});
/** A product's creator (for its audit tags and the person's rights), or 404. */
const creatorOf = async (id: string) => {
const [p] = await db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, id));
if (!p) throw new Problem(404, "not_found", "No such product.");
return p.creatorId;
};
const catalogView = (c: typeof schema.catalog.$inferSelect) => ({
id: c.id,
name: c.name,
@@ -1603,7 +1629,7 @@ export function createApp({
"/products",
async ({ person, query }) => {
const adult = await personAdult(person);
const rows = await products.list({ ...query, adult });
const rows = await products.list({ ...query, adult, viewerId: person?.id });
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername, r)) };
},
{
@@ -1611,6 +1637,8 @@ export function createApp({
q: t.Optional(t.String({ maxLength: 100 })),
creator: t.Optional(t.String({ maxLength: 64 })),
category: t.Optional(t.Numeric({ minimum: 1, maximum: 2_147_483_647, multipleOf: 1 })),
tag: t.Optional(t.String({ maxLength: 24 })),
catalog: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
}),
},
@@ -1619,7 +1647,7 @@ export function createApp({
"/products/:id",
async ({ person, params }) => {
const adult = await personAdult(person);
const p = await productCall(() => products.get(params.id, adult));
const p = await productCall(() => products.get(params.id, adult, productViewer(person)));
const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
const latest = p.revisions.find((r) => r.number === p.product.latest);
return {
@@ -1637,6 +1665,7 @@ export function createApp({
inLibrary: lib !== undefined,
stayOn: lib?.stayOn ?? null,
mine: person?.id === p.product.creatorId,
permissions: person ? [...(await perms.at(person, productScope(p.product.id)))] : [],
available: person ? await products.available(person.id, p.product.id) : false,
catalogs: await Promise.all(
(await products.hostingCatalogs(p.product.id)).map(async (c) => ({
@@ -1652,9 +1681,21 @@ export function createApp({
.patch(
"/products/:id",
async ({ me, params, body }) => {
const p = await productCall(() => products.update(me.id, params.id, body));
await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) });
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
const creatorId = await creatorOf(params.id);
await need(me, "product:edit", productScope(params.id));
const { reason = "", ...input } = body;
const done = await productCall(async () =>
products.updateInfo(me.id, params.id, input, reason, await editRights(me, params.id, creatorId)),
);
if (done.entry)
await audited(me.id, "product.edit", params.id, { fields: done.fields, ...(reason ? { reason } : {}) }, {
product: params.id,
person: creatorId,
});
return {
product: productView(done.product, undefined, undefined, await products.latestParent(done.product)),
fields: done.fields,
};
},
{
signedIn: true,
@@ -1663,6 +1704,115 @@ export function createApp({
name: t.Optional(t.String({ maxLength: 64 })),
description: t.Optional(t.String({ maxLength: 20000 })),
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",
async ({ me, params }) => {
const creatorId = await creatorOf(params.id);
await need(me, "product:publish", productScope(params.id));
const p = await productCall(() => products.publish(me.id, params.id));
await audited(me.id, "product.publish", p.id, { name: p.name }, { product: p.id, person: creatorId });
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
},
{ signedIn: true, params: productParam },
)
// The info history: who changed what, and why.
.get(
"/products/:id/history",
async ({ me, params }) => {
const creatorId = await creatorOf(params.id);
await need(me, "audit:read", productScope(params.id));
const entries = await products.history(params.id);
return {
entries: entries.map((e) => ({
id: e.id,
at: e.at.toISOString(),
kind: e.kind as "edit" | "revert" | "lock" | "publish",
by: e.by ?? "",
byCreator: e.byId === creatorId,
changes: e.changes,
reason: e.reason,
revertOf: e.revertOf,
})),
};
},
{ signedIn: true, params: productParam },
)
.post(
"/products/:id/history/:entry/revert",
async ({ me, params, body }) => {
const creatorId = await creatorOf(params.id);
await need(me, "product:edit", productScope(params.id));
const reason = body.reason ?? "";
const done = await productCall(async () =>
products.revert(me.id, params.id, params.entry, reason, await editRights(me, params.id, creatorId)),
);
await audited(
me.id,
"product.revert",
params.id,
{ entry: params.entry, fields: done.fields, ...(reason ? { reason } : {}) },
{ product: params.id, person: creatorId },
);
return {
product: productView(done.product, undefined, undefined, await products.latestParent(done.product)),
fields: done.fields,
};
},
{
signedIn: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), entry: t.Numeric({ minimum: 1, multipleOf: 1 }) }),
body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }),
},
)
// Moderators lock info fields, so only people with product:edit-locked change them.
.put(
"/products/:id/locks",
async ({ me, params, body }) => {
const creatorId = await creatorOf(params.id);
await need(me, "product:lock", productScope(params.id));
const p = await productCall(() => products.setLocks(me.id, params.id, body.fields, body.reason));
await audited(me.id, "product.lock", p.id, { fields: p.locked, reason: body.reason.trim() }, {
product: p.id,
person: creatorId,
});
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
},
{
signedIn: true,
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"),
t.Literal("category"),
]),
{ maxItems: 6 },
),
reason: t.String({ maxLength: 500 }),
}),
},
)
@@ -1699,7 +1849,9 @@ export function createApp({
.get(
"/products/:id/files",
async ({ person, params }) => {
const r = await productCall(async () => products.revisionFor(person?.id, params.id, await personAdult(person)));
const r = await productCall(async () =>
products.revisionFor(person?.id, params.id, await personAdult(person), productViewer(person)),
);
return {
product: r.product.id,
revision: r.revision.number,
@@ -1738,7 +1890,7 @@ export function createApp({
"/library/:id",
async ({ me, params }) => {
try {
await products.add(me.id, params.id, await personAdult(me));
await products.add(me.id, params.id, await personAdult(me), productViewer(me));
} catch (e) {
if (e instanceof ProductError && e.slug === "members_only")
throw new Problem(e.status, e.slug, e.message, { catalogs: await products.hostingCatalogs(params.id) });
@@ -1868,6 +2020,34 @@ export function createApp({
)
// The console.
.get(
"/console/products",
async ({ me, query }) => {
const mod = isModerator(me);
const all = mod && query.all === "1";
const rows = await products.manage({ creatorId: all ? undefined : me.id, q: query.q?.trim(), status: query.status });
if (!mod && rows.length === 0 && !query.q && !query.status && (await perms.ownProducts(me.id)).length === 0)
throw new Problem(403, "forbidden", "You haven't made any products.");
return {
products: rows.map((r) => ({
...productView(r.product, r.creator, r.creatorUsername, r),
libraries: r.libraries,
})),
moderator: mod,
all,
};
},
{
signedIn: true,
query: t.Object({
q: t.Optional(t.String({ maxLength: 100 })),
status: t.Optional(
t.Union(["draft", "published", "unlisted", "deleted", "removed", "locked"].map((s) => t.Literal(s))),
),
all: t.Optional(t.Literal("1")),
}),
},
)
.get(
"/console/catalogs",
async ({ me }) => {
@@ -1929,6 +2109,7 @@ export function createApp({
sections,
mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length },
todo: {
drafts: scope.products.length ? await products.draftCount(me.id) : 0,
memberRequests: requests?.n ?? 0,
pendingCatalogs: moderation?.counts.pendingCatalogs ?? 0,
waitingPeople: moderation?.counts.waiting ?? 0,
+9 -1
View File
@@ -67,12 +67,20 @@ const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageServic
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their
// parent's info (IMVU was unavailable, or they predate it) get it, starting now.
// parent's info (IMVU was unavailable, or they predate it) get it, starting now; drafts never published go after 30
// days.
const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e));
const purgeDrafts = () =>
products
.purgeDrafts()
.then((n) => n > 0 && console.log(`deleted ${n} drafts never published`))
.catch((e) => console.error("drafts:", e));
parentInfo();
purgeDrafts();
setInterval(() => {
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
parentInfo();
purgeDrafts();
}, 10 * 60 * 1000);
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
+336 -28
View File
@@ -20,12 +20,38 @@ import { cleanHTML, maxDescription } from "./html";
import type { ImvuClient } from "./imvu";
import type { Signer } from "./keys";
const { catalog, catalogMember, product, productRevision, hosting, library, roleAssignment, user } = schema;
const { catalog, catalogChange, catalogMember, product, productInfoChange, productRevision, hosting, library, roleAssignment, user } =
schema;
export type Catalog = typeof catalog.$inferSelect;
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", "category"] as const;
export type InfoField = (typeof infoFields)[number];
/** `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 };
/** Who's looking: drafts are only for their creator and moderators. */
export type ProductViewer = { id: string; moderator: boolean };
/** Drafts never published are deleted after this long. */
export const draftDays = 30;
/** An error for the person; the routes turn it into a Problem. */
export class ProductError extends Error {
constructor(
@@ -50,6 +76,19 @@ const nameRule = (s: unknown, max: number) => {
return v;
};
/** Tags as people type them: lowercase words (letters, digits, spaces, dashes), at most 10, each up to 24. */
export function checkTags(tags: unknown): string[] | undefined {
if (!Array.isArray(tags) || tags.length > 10) return undefined;
const out: string[] = [];
for (const t of tags) {
const v = typeof t === "string" ? t.trim().toLowerCase().replace(/\s+/g, " ") : "";
if (!v) continue;
if (v.length > 24 || !/^[\p{L}\p{N}][\p{L}\p{N} -]*$/u.test(v)) return undefined;
if (!out.includes(v)) out.push(v);
}
return out;
}
/** A file list as catalogs send it: checked and sorted by name. */
export function checkFiles(files: unknown): FileEntry[] | undefined {
if (!Array.isArray(files) || files.length === 0 || files.length > 512) return undefined;
@@ -75,10 +114,10 @@ const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
/**
* Whether a product is available to a person (docs/products.md, "Member"): they made it, or a catalog hosting it is
* approved, hasn't banned them, and is open or has them as a member or on its team.
* Whether a product is available to a person (docs/products.md, "Member"): it's published, and they made it, or a
* catalog hosting it is approved, hasn't banned them, and is open or has them as a member or on its team.
*/
export const availableTo = (personId: string) => sql`(
export const availableTo = (personId: string) => sql`(${product.status} = 'published' and (
${product.creatorId} = ${personId} or exists (
select 1 from ${hosting} h
join ${catalog} c on c.id = h.catalog_id
@@ -87,7 +126,7 @@ export const availableTo = (personId: string) => sql`(
and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId}
or exists (select 1 from ${roleAssignment} r
where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id))
))`;
)))`;
/** Each product's latest revision (joined on product.latest, for lists). */
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
@@ -239,7 +278,7 @@ export class Products {
const name = nameRule(body.name, 64) ?? "Untitled";
const [made] = await tx
.insert(product)
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating })
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating, status: "draft" })
.returning();
p = made!;
}
@@ -265,7 +304,7 @@ export class Products {
const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general";
await tx.update(product).set({ latest: number, rating }).where(eq(product.id, p.id));
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
return { product: p.id, revision: number };
return { product: p.id, revision: number, draft: p.status === "draft" };
});
// The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries).
if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {});
@@ -354,16 +393,38 @@ export class Products {
}
/**
* Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
* Published products, newest first; adult ones only if adult; unlisted ones only for their creator. `category`:
* only those whose latest revision's parent is in that IMVU category (anywhere in its path); `tag`: those with
* the tag; `catalog`: those the catalog hosts. Each comes with its latest revision's parent and parent info.
*/
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)];
async list(opts: {
q?: string;
creator?: string;
category?: number;
tag?: string;
catalog?: string;
adult: boolean;
viewerId?: string;
limit?: number;
}) {
const conds = [
ne(product.latest, 0),
eq(product.status, "published"),
isNull(product.deletedAt),
isNull(product.removedAt),
opts.viewerId ? or(eq(product.listed, true), eq(product.creatorId, opts.viewerId))! : eq(product.listed, true),
];
if (!opts.adult) conds.push(eq(product.rating, "general"));
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)
conds.push(sql`exists (select 1 from ${hosting} h where h.product_id = ${product.id} and h.catalog_id = ${opts.catalog})`);
const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
return this.db
.select({
@@ -390,7 +451,7 @@ export class Products {
return r;
}
async get(id: string, adult: boolean) {
async get(id: string, adult: boolean, viewer?: ProductViewer) {
const [row] = await this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
.from(product)
@@ -398,6 +459,8 @@ export class Products {
.where(eq(product.id, id));
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (row.product.status === "draft" && !(viewer?.moderator || viewer?.id === row.product.creatorId))
throw new ProductError(404, "not_found", "No such product.");
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db
.select()
@@ -412,27 +475,271 @@ export class Products {
return { ...row, revisions, hosts };
}
async update(personId: string, id: string, input: { name?: string; description?: string; rating?: string }) {
const p = await this.own(personId, id);
const set: Partial<Product> = {};
// ---- product info: edits, history, locks, publishing ----
/** A product for changing its info: not removed; deleted ones only by others than the creator (moderators). */
private async editable(id: string, rights: EditRights) {
const [p] = await this.db.select().from(product).where(eq(product.id, id));
if (!p || p.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (p.deletedAt && rights.creator) throw new ProductError(410, "deleted", "You deleted this product.");
return p;
}
/**
* Changes a product's info (the route checked product:edit). Only fields that change are kept in its history.
* Someone other than the creator must give a reason; locked fields need editLocked; lowering the rating needs
* rateLower (and files rated general). Returns the product, and the history entry if anything changed.
*/
async updateInfo(
byId: string,
id: string,
input: InfoInput,
reason: string,
rights: EditRights,
revertOf?: number,
): Promise<{ product: Product; entry?: number; fields: InfoField[] }> {
const p = await this.editable(id, rights);
reason = reason.trim().slice(0, 500);
if (!rights.creator && !reason)
throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason with the change.");
const next: Partial<Pick<Product, InfoField>> = {};
if (input.name !== undefined) {
const name = nameRule(input.name, 64);
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
set.name = name;
next.name = name;
}
if (input.description !== undefined) {
if (input.description.length > maxDescription)
throw new ProductError(400, "invalid", "That description is too long.");
set.description = cleanHTML(input.description);
next.description = cleanHTML(input.description);
}
if (input.rating !== undefined) {
if (input.rating === "adult") set.rating = "adult";
else if (input.rating === "general" && p.rating === "adult")
// Lowering is reviewed by moderators first (design.md); until that exists, it's refused.
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
if (input.rating !== "general" && input.rating !== "adult")
throw new ProductError(400, "invalid", "The rating must be general or adult.");
next.rating = input.rating;
}
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
return updated!;
if (input.tags !== undefined) {
const tags = checkTags(input.tags);
if (!tags)
throw new ProductError(400, "invalid", "Up to 10 tags, each up to 24 letters, digits, spaces or dashes.");
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 }> = {};
for (const f of infoFields)
if (next[f] !== undefined && !same(next[f], p[f])) changes[f] = { from: p[f], to: next[f] };
const fields = Object.keys(changes) as InfoField[];
if (fields.length === 0) return { product: p, fields };
const locked = fields.filter((f) => p.locked.includes(f));
if (locked.length && !rights.editLocked)
throw new ProductError(403, "locked", `A moderator locked this product's ${locked.join(", ")}.`);
if (changes.rating?.to === "general") {
if (!rights.rateLower)
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
const [adultFiles] = await this.db
.select({ n: productRevision.number })
.from(productRevision)
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"), eq(productRevision.retracted, false)))
.limit(1);
if (adultFiles)
throw new ProductError(400, "files_adult", `Revision ${adultFiles.n}'s files are rated adult; retract it first.`);
}
return this.db.transaction(async (tx) => {
const [updated] = await tx.update(product).set(next).where(eq(product.id, id)).returning();
const [entry] = await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: revertOf ? "revert" : "edit", changes, reason, revertOf })
.returning({ id: productInfoChange.id });
return { product: updated!, entry: entry!.id, fields };
});
}
/** A draft becomes published: listed (unless unlisted), and people can get it. */
async publish(byId: string, id: string) {
const p = await this.editable(id, { creator: true, editLocked: false, rateLower: false });
if (p.status !== "draft") throw new ProductError(409, "published", "It's published already.");
if (!nameRule(p.name, 64)) throw new ProductError(400, "invalid", "Give it a name first.");
return this.db.transaction(async (tx) => {
const [updated] = await tx
.update(product)
.set({ status: "published", publishedAt: new Date() })
.where(eq(product.id, id))
.returning();
await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: "publish", changes: { status: { from: "draft", to: "published" } } });
return updated!;
});
}
/** Moderators lock info fields (the route checked product:lock); the reason is kept in the history. */
async setLocks(byId: string, id: string, fields: string[], reason: string) {
const p = await this.editable(id, { creator: false, editLocked: true, rateLower: true });
const want = infoFields.filter((f) => fields.includes(f));
if (want.length !== new Set(fields).size)
throw new ProductError(400, "invalid", `Only these can be locked: ${infoFields.join(", ")}.`);
reason = reason.trim().slice(0, 500);
if (!reason) throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason.");
if (JSON.stringify(want) === JSON.stringify(infoFields.filter((f) => p.locked.includes(f)))) return p;
return this.db.transaction(async (tx) => {
const [updated] = await tx.update(product).set({ locked: want }).where(eq(product.id, id)).returning();
await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: "lock", changes: { locked: { from: p.locked, to: want } }, reason });
return updated!;
});
}
/**
* 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
.select({
id: productInfoChange.id,
at: productInfoChange.createdAt,
kind: productInfoChange.kind,
changes: productInfoChange.changes,
reason: productInfoChange.reason,
revertOf: productInfoChange.revertOf,
byId: productInfoChange.byId,
by: user.displayUsername,
})
.from(productInfoChange)
.leftJoin(user, eq(user.id, productInfoChange.byId))
.where(eq(productInfoChange.productId, id))
.orderBy(desc(productInfoChange.id));
}
/** Puts back what a history entry changed, as a new entry (the same rules as an edit). */
async revert(byId: string, id: string, entryId: number, reason: string, rights: EditRights) {
const [e] = await this.db
.select()
.from(productInfoChange)
.where(and(eq(productInfoChange.id, entryId), eq(productInfoChange.productId, id)));
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;
}
/** Deletes drafts never published within draftDays; their catalogs are told to delete the files. */
async purgeDrafts(now = new Date()): Promise<number> {
const before = new Date(now.getTime() - draftDays * 24 * 60 * 60 * 1000);
const stale = await this.db
.select({ id: product.id })
.from(product)
.where(and(eq(product.status, "draft"), sql`${product.createdAt} < ${before.toISOString()}::timestamptz`));
for (const { id } of stale) {
await this.db.transaction(async (tx) => {
const hosted = await tx
.select({ catalogId: hosting.catalogId, number: hosting.number, files: productRevision.files })
.from(hosting)
.innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)))
.where(eq(hosting.productId, id));
for (const h of hosted)
await tx
.insert(catalogChange)
.values({ catalogId: h.catalogId, data: { type: "delete", product: id, revision: h.number, files: h.files } });
await tx.delete(hosting).where(eq(hosting.productId, id));
await tx.delete(product).where(eq(product.id, id));
});
}
return stale.length;
}
/**
* Products for the console: a person's own, or with `all` (moderators) everyone's; drafts, unlisted, deleted and
* removed ones included. `status`: draft, published, unlisted, deleted, removed or locked.
*/
async manage(opts: { creatorId?: string; q?: string; status?: string; limit?: number }) {
const conds = [ne(product.latest, 0)];
if (opts.creatorId) conds.push(eq(product.creatorId, opts.creatorId));
if (opts.q) {
const like = `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
conds.push(or(ilike(product.name, like), eq(product.id, opts.q), ilike(user.username, like))!);
}
const live = and(isNull(product.deletedAt), isNull(product.removedAt))!;
switch (opts.status) {
case "draft":
conds.push(eq(product.status, "draft"), live);
break;
case "published":
conds.push(eq(product.status, "published"), eq(product.listed, true), live);
break;
case "unlisted":
conds.push(eq(product.status, "published"), eq(product.listed, false), live);
break;
case "deleted":
conds.push(sql`${product.deletedAt} is not null`, isNull(product.removedAt));
break;
case "removed":
conds.push(sql`${product.removedAt} is not null`);
break;
case "locked":
conds.push(sql`jsonb_array_length(${product.locked}) > 0`);
break;
}
return this.db
.select({
product,
creator: user.displayUsername,
creatorUsername: user.username,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
libraries: sql<number>`(select count(*) from ${library} l where l.product_id = ${product.id})::int`,
})
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(...conds))
.orderBy(desc(product.updatedAt))
.limit(Math.max(1, Math.min(opts.limit ?? 100, 200)));
}
/** How many drafts a person has (for the console's to-dos). */
async draftCount(creatorId: string) {
const [row] = await this.db
.select({ n: sql<number>`count(*)::int` })
.from(product)
.where(and(eq(product.creatorId, creatorId), eq(product.status, "draft"), isNull(product.deletedAt), ne(product.latest, 0)));
return row?.n ?? 0;
}
async retract(personId: string, id: string, number: number) {
@@ -474,8 +781,9 @@ export class Products {
return row;
}
async add(personId: string, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
async add(personId: string, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
if (p.product.status !== "published") throw new ProductError(409, "draft", "This product isn't published yet.");
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product.");
if (!(await this.available(personId, productId)))
@@ -524,8 +832,8 @@ export class Products {
* The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to
* the nearest earlier one that isn't (or later, if none).
*/
async revisionFor(personId: string | undefined, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
async revisionFor(personId: string | undefined, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
const want = pin ?? p.product.latest;
const usable = p.revisions.filter((r) => !r.retracted);
+1
View File
@@ -107,6 +107,7 @@ async function upload(maker: Person, c: { id: string; auth: Record<string, strin
c.auth,
);
expect(r.status).toBe(201);
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
return r.json.product as string;
}
+142 -2
View File
@@ -122,10 +122,22 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401);
const made = await revision(up.json.token);
expect(made.status).toBe(201);
expect(made.json.revision).toBe(1);
expect(made.json).toMatchObject({ revision: 1, draft: true });
const id = made.json.product as string;
expect(id).toMatch(/^[a-z2-7]{10}$/);
// A new product is a draft: only its creator (and moderators) see it, and nobody can get it until it's published.
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).status).toBe(404);
expect((await call(kid, "GET", `/api/v1/products/${id}`)).status).toBe(404);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).status).toBe(404);
expect((await call(admin, "GET", `/api/v1/products/${id}`)).json.product.status).toBe("draft");
expect((await call(maker, "GET", `/api/v1/products/${id}`)).json.product).toMatchObject({ status: "draft", publishedAt: null });
expect((await call(maker, "PUT", `/api/v1/library/${id}`)).json.error).toBe("draft");
expect((await call(maker, "GET", "/api/v1/products?creator=maker")).json.products).toEqual([]);
expect((await call(kid, "POST", `/api/v1/products/${id}/publish`)).status).toBe(403);
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.product.status).toBe("published");
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.error).toBe("published");
// Its page: info, revisions (files sorted), hosting.
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" });
@@ -147,7 +159,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect((await revision(up2)).status).toBe(400);
expect((await revision(up2, { notes: "same" })).status).toBe(409);
const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] });
expect(r2.json).toEqual({ product: id, revision: 2 });
expect(r2.json).toEqual({ product: id, revision: 2, draft: false });
// Someone else's upload token can't add to it.
expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403);
@@ -174,6 +186,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
// Adult products are only for adults.
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product;
await call(maker, "POST", `/api/v1/products/${adultID}/publish`);
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
@@ -230,6 +243,7 @@ test("derived products carry their IMVU parent's image and categories", async ()
const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth);
expect(r.status).toBe(201);
await call(dresser, "POST", `/api/v1/products/${r.json.product}/publish`);
return r.json.product as string;
};
const coatID = await upload("Dresser Coat", 7001);
@@ -266,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 });
@@ -287,6 +323,7 @@ test("creators delete products (those who have them keep them); moderation remov
const make = async (name: string) => {
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`);
return r.json.product as string;
};
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
@@ -313,3 +350,106 @@ test("creators delete products (those who have them keep them); moderation remov
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
});
test("product info: moderators edit with reasons and lock fields; history and reverts; unlisted and tags", async () => {
const { admin, catalogID, auth } = shared;
const maker = await person("tailor", { id: 521, ageVerified: true });
const other = await person("browser", { id: 522, ageVerified: true });
const mod = await person("warden", { id: 523, ageVerified: true });
const modID = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${modID}/role`, { role: "moderator" })).status).toBe(200);
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const id = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("vest.xml", 7)], rating: "general", parent: 0, name: "Vest", notes: "" }, auth)).json.product as string;
// The creator sets it up while it's a draft: tags (cleaned), unlisted; then publishes.
const set = await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: [" Formal ", "formal", "Wool Blend"], listed: false });
expect(set.json.fields).toEqual(["tags", "listed"]);
expect(set.json.product).toMatchObject({ tags: ["formal", "wool blend"], listed: false });
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: ["<b>"] })).json.error).toBe("invalid");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: false })).json.fields).toEqual([]);
await call(maker, "POST", `/api/v1/products/${id}/publish`);
// Unlisted: not in lists or search for others (its creator still sees it); its link works, and people can get it.
const names = async (p: Person | undefined, qs: string) => (await call(p, "GET", `/api/v1/products?${qs}`)).json.products.map((x: any) => x.name);
expect(await names(other, "creator=tailor")).toEqual([]);
expect(await names(maker, "creator=tailor")).toEqual(["Vest"]);
expect((await call(other, "GET", `/api/v1/products/${id}`)).status).toBe(200);
expect((await call(other, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: true });
expect(await names(other, "tag=formal")).toEqual(["Vest"]);
expect(await names(other, "tag=Wool%20Blend")).toEqual(["Vest"]);
expect(await names(other, "tag=denim")).toEqual([]);
expect(await names(other, `creator=tailor&catalog=${catalogID}`)).toEqual(["Vest"]);
// Others can't edit; a moderator can, with a reason the creator sees.
expect((await call(other, "PATCH", `/api/v1/products/${id}`, { name: "Mine" })).json).toMatchObject({
error: "forbidden",
missing: { permission: "product:edit" },
});
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest" })).json.error).toBe("reason_needed");
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest", reason: "Misleading name" })).status).toBe(200);
// Locks: the creator can't change a locked field; moderators can. The page lists what the person may do.
expect((await call(maker, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "x" })).status).toBe(403);
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "" })).json.error).toBe("reason_needed");
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "Keeps renaming it" })).json.product.locked).toEqual(["name"]);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Vest!" })).json.error).toBe("locked");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { description: "<p>Warm</p>" })).status).toBe(200);
const page = (await call(maker, "GET", `/api/v1/products/${id}`)).json;
expect(page.product.locked).toEqual(["name"]);
expect(page.permissions).toContain("product:edit");
expect(page.permissions).not.toContain("product:edit-locked");
expect((await call(mod, "GET", `/api/v1/products/${id}`)).json.permissions).toContain("product:edit-locked");
// Ratings: anyone with product:edit raises it; lowering is for moderators, and not below its files' rating.
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "adult" })).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "general" })).json.error).toBe("review_needed");
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { rating: "general", reason: "Not adult" })).status).toBe(200);
// The history: every change, newest first, with who and why. Only the creator and moderators read it.
expect((await call(other, "GET", `/api/v1/products/${id}/history`)).status).toBe(403);
const history = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries;
expect(history.map((e: any) => [e.kind, Object.keys(e.changes).join(), e.by, e.byCreator])).toEqual([
["edit", "rating", "warden", false],
["edit", "rating", "tailor", true],
["edit", "description", "tailor", true],
["lock", "locked", "warden", false],
["edit", "name", "warden", false],
["edit", "listed", "tailor", true],
["publish", "status", "tailor", true],
["edit", "tags,listed", "tailor", true],
]);
expect(history[4]).toMatchObject({ reason: "Misleading name", changes: { name: { from: "Vest", to: "Wool Vest" } } });
// Reverting puts back what an entry changed, by the same rules: the name is locked for the creator.
const rename = history[4].id;
expect((await call(maker, "POST", `/api/v1/products/${id}/history/${rename}/revert`, {})).json.error).toBe("locked");
const reverted = await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "My mistake" });
expect(reverted.json.product.name).toBe("Vest");
expect((await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "Again" })).json.error).toBe("unchanged");
const latest = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries[0];
expect(latest).toMatchObject({ kind: "revert", revertOf: rename, reason: "My mistake" });
// Moderators' edits are in the creator's audit log, tagged with the product.
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${id}`)).json.entries;
expect(audit.map((e: any) => e.action)).toContain("product.lock");
// The console's product list: the creator's own (drafts too); moderators everyone's, filtered.
const draftToken = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const draft = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: draftToken, files: [file("cap.xml", 8)], rating: "general", parent: 0, name: "Cap", notes: "" }, auth)).json.product as string;
const mine = (await call(maker, "GET", "/api/v1/console/products")).json;
expect(mine.products.map((p: any) => [p.name, p.status, p.libraries])).toEqual([["Cap", "draft", 0], ["Vest", "published", 1]]);
expect((await call(maker, "GET", "/api/v1/console")).json.todo.drafts).toBe(1);
expect((await call(other, "GET", "/api/v1/console/products")).status).toBe(403);
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=locked")).json.products.map((p: any) => p.id)).toEqual([id]);
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=draft&q=tailor")).json.products.map((p: any) => p.id)).toEqual([draft]);
// Drafts never published go after 30 days, and their catalogs are told to delete the files.
expect(await hub.products.purgeDrafts(new Date(Date.now() + 29 * 24 * 60 * 60 * 1000))).toBe(0);
expect(await hub.products.purgeDrafts(new Date(Date.now() + 31 * 24 * 60 * 60 * 1000))).toBeGreaterThanOrEqual(1);
expect((await call(maker, "GET", `/api/v1/products/${draft}`)).status).toBe(404);
expect((await call(maker, "GET", `/api/v1/products/${id}`)).status).toBe(200);
const changes = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, auth)).json.changes;
expect(changes.find((c: any) => c.product === draft)).toMatchObject({ type: "delete", revision: 1, files: [file("cap.xml", 8)] });
});