feat: product drafts, moderator edits, info history, unlisted products, tags and categories (#8)
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:
1 parent
2cbedd26fc
commit
e07a6c6dac
24 files changed
+4248
-95
No files matched your search
+190
-9
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)] });
|
||||
});
|
||||
Reference in new issue
Block a user