feat: product drafts, moderator edits with reasons and locks, info history, unlisted products and tags
New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and publishes it. Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings (product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why, and can be undone by the same rules. Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do. A product's own category is left for later: the hub has no list of IMVU's categories to pick from. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2cbedd26fc
commit
8793b5afd8
24 files changed
+4106
-96
No files matched your search
+167
-8
@@ -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,
|
||||
@@ -333,11 +333,35 @@ export function createApp({
|
||||
parent: latest?.parent ?? 0,
|
||||
image: latest?.parentInfo?.image ?? null,
|
||||
categories: latest?.parentInfo?.categories ?? [],
|
||||
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 +1627,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 +1635,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 +1645,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 +1663,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 +1679,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 +1702,95 @@ 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()),
|
||||
reason: t.Optional(t.String({ maxLength: 500 })),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// 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")]),
|
||||
{ maxItems: 5 },
|
||||
),
|
||||
reason: t.String({ maxLength: 500 }),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -1699,7 +1827,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 +1868,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 +1998,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 +2087,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})`);
|
||||
+291
-27
@@ -20,12 +20,27 @@ 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"] as const;
|
||||
export type InfoField = (typeof infoFields)[number];
|
||||
export type InfoInput = { name?: string; description?: string; rating?: string; tags?: string[]; listed?: boolean };
|
||||
|
||||
/** 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 +65,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 +103,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 +115,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 +267,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 +293,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 +382,36 @@ 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`);
|
||||
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 +438,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 +446,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 +462,240 @@ 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;
|
||||
|
||||
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!;
|
||||
});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
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 +737,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 +788,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);
|
||||
@@ -287,6 +301,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 +328,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