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

Merged
dhkatz merged 2 commits from feat/product-info into main 2026-11-05 14:23:27 +00:00
24 changed files with 4106 additions and 96 deletions
Showing only changes of commit 8793b5afd8 - Show all commits

No files matched your search

+167 -8
View File
@@ -26,7 +26,7 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime"; import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms"; import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys"; import { Signer } from "./keys";
import { availableTo, ProductError, Products } from "./products"; import { availableTo, ProductError, Products, type EditRights, type ProductViewer } from "./products";
import { import {
chatId, chatId,
HostedError, HostedError,
@@ -333,11 +333,35 @@ export function createApp({
parent: latest?.parent ?? 0, parent: latest?.parent ?? 0,
image: latest?.parentInfo?.image ?? null, image: latest?.parentInfo?.image ?? null,
categories: latest?.parentInfo?.categories ?? [], categories: latest?.parentInfo?.categories ?? [],
status: p.status as "draft" | "published",
listed: p.listed,
tags: p.tags,
locked: p.locked,
deleted: p.deletedAt !== null, deleted: p.deletedAt !== null,
removed: p.removedAt !== null,
publishedAt: p.publishedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(), createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.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) => ({ const catalogView = (c: typeof schema.catalog.$inferSelect) => ({
id: c.id, id: c.id,
name: c.name, name: c.name,
@@ -1603,7 +1627,7 @@ export function createApp({
"/products", "/products",
async ({ person, query }) => { async ({ person, query }) => {
const adult = await personAdult(person); 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)) }; 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 })), q: t.Optional(t.String({ maxLength: 100 })),
creator: t.Optional(t.String({ maxLength: 64 })), creator: t.Optional(t.String({ maxLength: 64 })),
category: t.Optional(t.Numeric({ minimum: 1, maximum: 2_147_483_647, multipleOf: 1 })), 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 })), limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
}), }),
}, },
@@ -1619,7 +1645,7 @@ export function createApp({
"/products/:id", "/products/:id",
async ({ person, params }) => { async ({ person, params }) => {
const adult = await personAdult(person); 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 lib = person ? await products.inLibrary(person.id, params.id) : undefined;
const latest = p.revisions.find((r) => r.number === p.product.latest); const latest = p.revisions.find((r) => r.number === p.product.latest);
return { return {
@@ -1637,6 +1663,7 @@ export function createApp({
inLibrary: lib !== undefined, inLibrary: lib !== undefined,
stayOn: lib?.stayOn ?? null, stayOn: lib?.stayOn ?? null,
mine: person?.id === p.product.creatorId, 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, available: person ? await products.available(person.id, p.product.id) : false,
catalogs: await Promise.all( catalogs: await Promise.all(
(await products.hostingCatalogs(p.product.id)).map(async (c) => ({ (await products.hostingCatalogs(p.product.id)).map(async (c) => ({
@@ -1652,9 +1679,21 @@ export function createApp({
.patch( .patch(
"/products/:id", "/products/:id",
async ({ me, params, body }) => { async ({ me, params, body }) => {
const p = await productCall(() => products.update(me.id, params.id, body)); const creatorId = await creatorOf(params.id);
await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) }); await need(me, "product:edit", productScope(params.id));
return { product: productView(p, undefined, undefined, await products.latestParent(p)) }; 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, signedIn: true,
@@ -1663,6 +1702,95 @@ export function createApp({
name: t.Optional(t.String({ maxLength: 64 })), name: t.Optional(t.String({ maxLength: 64 })),
description: t.Optional(t.String({ maxLength: 20000 })), description: t.Optional(t.String({ maxLength: 20000 })),
rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])), rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])),
tags: t.Optional(t.Array(t.String({ maxLength: 64 }), { maxItems: 20 })),
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( .get(
"/products/:id/files", "/products/:id/files",
async ({ person, params }) => { 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 { return {
product: r.product.id, product: r.product.id,
revision: r.revision.number, revision: r.revision.number,
@@ -1738,7 +1868,7 @@ export function createApp({
"/library/:id", "/library/:id",
async ({ me, params }) => { async ({ me, params }) => {
try { 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) { } catch (e) {
if (e instanceof ProductError && e.slug === "members_only") if (e instanceof ProductError && e.slug === "members_only")
throw new Problem(e.status, e.slug, e.message, { catalogs: await products.hostingCatalogs(params.id) }); throw new Problem(e.status, e.slug, e.message, { catalogs: await products.hostingCatalogs(params.id) });
@@ -1868,6 +1998,34 @@ export function createApp({
) )
// The console. // 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( .get(
"/console/catalogs", "/console/catalogs",
async ({ me }) => { async ({ me }) => {
@@ -1929,6 +2087,7 @@ export function createApp({
sections, sections,
mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length }, mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length },
todo: { todo: {
drafts: scope.products.length ? await products.draftCount(me.id) : 0,
memberRequests: requests?.n ?? 0, memberRequests: requests?.n ?? 0,
pendingCatalogs: moderation?.counts.pendingCatalogs ?? 0, pendingCatalogs: moderation?.counts.pendingCatalogs ?? 0,
waitingPeople: moderation?.counts.waiting ?? 0, waitingPeople: moderation?.counts.waiting ?? 0,
+9 -1
View File
@@ -67,12 +67,20 @@ const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageServic
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); 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 // 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 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(); parentInfo();
purgeDrafts();
setInterval(() => { setInterval(() => {
age.recheckDue(50).catch((e) => console.error("age re-checks:", e)); age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e)); rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
parentInfo(); parentInfo();
purgeDrafts();
}, 10 * 60 * 1000); }, 10 * 60 * 1000);
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`); console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
+291 -27
View File
@@ -20,12 +20,27 @@ import { cleanHTML, maxDescription } from "./html";
import type { ImvuClient } from "./imvu"; import type { ImvuClient } from "./imvu";
import type { Signer } from "./keys"; 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 Catalog = typeof catalog.$inferSelect;
export type Product = typeof product.$inferSelect; export type Product = typeof product.$inferSelect;
export type Revision = typeof productRevision.$inferSelect; export type Revision = typeof productRevision.$inferSelect;
/** Info fields people edit (and moderators lock). */
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. */ /** An error for the person; the routes turn it into a Problem. */
export class ProductError extends Error { export class ProductError extends Error {
constructor( constructor(
@@ -50,6 +65,19 @@ const nameRule = (s: unknown, max: number) => {
return v; 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. */ /** A file list as catalogs send it: checked and sorted by name. */
export function checkFiles(files: unknown): FileEntry[] | undefined { export function checkFiles(files: unknown): FileEntry[] | undefined {
if (!Array.isArray(files) || files.length === 0 || files.length > 512) return 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; 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 * Whether a product is available to a person (docs/products.md, "Member"): it's published, and they made it, or a
* approved, hasn't banned them, and is open or has them as a member or on its team. * 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 ( ${product.creatorId} = ${personId} or exists (
select 1 from ${hosting} h select 1 from ${hosting} h
join ${catalog} c on c.id = h.catalog_id 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} and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId}
or exists (select 1 from ${roleAssignment} r or exists (select 1 from ${roleAssignment} r
where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id)) 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). */ /** Each product's latest revision (joined on product.latest, for lists). */
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest)); 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 name = nameRule(body.name, 64) ?? "Untitled";
const [made] = await tx const [made] = await tx
.insert(product) .insert(product)
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating }) .values({ id: newProductID(), creatorId: personId, name, rating: body.rating, status: "draft" })
.returning(); .returning();
p = made!; p = made!;
} }
@@ -265,7 +293,7 @@ export class Products {
const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general"; 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.update(product).set({ latest: number, rating }).where(eq(product.id, p.id));
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number }); 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). // 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(() => {}); 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 * Published products, newest first; adult ones only if adult; unlisted ones only for their creator. `category`:
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info. * 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 }) { async list(opts: {
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)]; 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.adult) conds.push(eq(product.rating, "general"));
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`)); if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`));
if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase())); if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
if (opts.category !== undefined) if (opts.category !== undefined)
conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`); conds.push(sql`${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)); const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
return this.db return this.db
.select({ .select({
@@ -390,7 +438,7 @@ export class Products {
return r; return r;
} }
async get(id: string, adult: boolean) { async get(id: string, adult: boolean, viewer?: ProductViewer) {
const [row] = await this.db const [row] = await this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username }) .select({ product, creator: user.displayUsername, creatorUsername: user.username })
.from(product) .from(product)
@@ -398,6 +446,8 @@ export class Products {
.where(eq(product.id, id)); .where(eq(product.id, id));
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product."); 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.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."); if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db const revisions = await this.db
.select() .select()
@@ -412,27 +462,240 @@ export class Products {
return { ...row, revisions, hosts }; return { ...row, revisions, hosts };
} }
async update(personId: string, id: string, input: { name?: string; description?: string; rating?: string }) { // ---- product info: edits, history, locks, publishing ----
const p = await this.own(personId, id);
const set: Partial<Product> = {}; /** 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) { if (input.name !== undefined) {
const name = nameRule(input.name, 64); const name = nameRule(input.name, 64);
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters."); 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 !== undefined) {
if (input.description.length > maxDescription) if (input.description.length > maxDescription)
throw new ProductError(400, "invalid", "That description is too long."); 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 !== undefined) {
if (input.rating === "adult") set.rating = "adult"; if (input.rating !== "general" && input.rating !== "adult")
else if (input.rating === "general" && p.rating === "adult") throw new ProductError(400, "invalid", "The rating must be general or adult.");
// Lowering is reviewed by moderators first (design.md); until that exists, it's refused. next.rating = input.rating;
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
} }
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning(); if (input.tags !== undefined) {
return updated!; 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) { async retract(personId: string, id: string, number: number) {
@@ -474,8 +737,9 @@ export class Products {
return row; return row;
} }
async add(personId: string, productId: string, adult: boolean) { async add(personId: string, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult); 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))) if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product."); throw new ProductError(410, "deleted", "Its creator deleted this product.");
if (!(await this.available(personId, productId))) 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 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). * the nearest earlier one that isn't (or later, if none).
*/ */
async revisionFor(personId: string | undefined, productId: string, adult: boolean) { async revisionFor(personId: string | undefined, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult); const p = await this.get(productId, adult, viewer);
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined; const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
const want = pin ?? p.product.latest; const want = pin ?? p.product.latest;
const usable = p.revisions.filter((r) => !r.retracted); const usable = p.revisions.filter((r) => !r.retracted);
+1
View File
@@ -107,6 +107,7 @@ async function upload(maker: Person, c: { id: string; auth: Record<string, strin
c.auth, c.auth,
); );
expect(r.status).toBe(201); 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; return r.json.product as string;
} }
+120 -2
View File
@@ -122,10 +122,22 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401); 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); const made = await revision(up.json.token);
expect(made.status).toBe(201); 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; const id = made.json.product as string;
expect(id).toMatch(/^[a-z2-7]{10}$/); 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. // Its page: info, revisions (files sorted), hosting.
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json; const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" }); 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)).status).toBe(400);
expect((await revision(up2, { notes: "same" })).status).toBe(409); 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)] }); 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. // 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); 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. // Adult products are only for adults.
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token; 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; 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(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, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
expect((await call(kid, "PUT", `/api/v1/library/${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 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); 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); expect(r.status).toBe(201);
await call(dresser, "POST", `/api/v1/products/${r.json.product}/publish`);
return r.json.product as string; return r.json.product as string;
}; };
const coatID = await upload("Dresser Coat", 7001); 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 make = async (name: string) => {
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token; 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); 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; 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); 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/products/${bad}/files`)).status).toBe(410);
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]); 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)] });
});
+2 -1
View File
@@ -32,9 +32,10 @@ export async function requireConsole(path: string): Promise<{ me: Person; consol
type Section = ConsoleData["sections"][number]; type Section = ConsoleData["sections"][number];
// Products are managed on their own pages for now; settings are on the overview. // Settings are on the overview.
const tabs: { section: Section; href: string; label: string }[] = [ const tabs: { section: Section; href: string; label: string }[] = [
{ section: "overview", href: "/console", label: "Overview" }, { section: "overview", href: "/console", label: "Overview" },
{ section: "products", href: "/console/products", label: "Products" },
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" }, { section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
{ section: "people", href: "/console/people", label: "People" }, { section: "people", href: "/console/people", label: "People" },
{ section: "audit", href: "/console/audit", label: "Audit" }, { section: "audit", href: "/console/audit", label: "Audit" },
+2 -1
View File
@@ -19,6 +19,7 @@ export default async function Console() {
const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined; const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
const todo: Stat[] = [ const todo: Stat[] = [
{ label: "Drafts", value: data.todo.drafts, note: "to review and publish", href: "/console/products?status=draft" },
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" }, { label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
]; ];
if (data.moderation) { if (data.moderation) {
@@ -28,7 +29,7 @@ export default async function Console() {
); );
} }
const mine: Stat[] = [ const mine: Stat[] = [
{ label: "Your Products", value: data.mine.products, note: "you created", href: `/products?creator=${encodeURIComponent(me.username)}` }, { label: "Your Products", value: data.mine.products, note: "you created", href: "/console/products" },
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" }, { label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" }, { label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
]; ];
+130
View File
@@ -0,0 +1,130 @@
import type { Metadata } from "next";
import Link from "next/link";
import { RatingTag, StateTags } from "@/components/products";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Products" };
const statuses = [
["", "Any status"],
["draft", "Drafts"],
["published", "Published"],
["unlisted", "Unlisted"],
["locked", "Locked"],
["deleted", "Deleted"],
["removed", "Removed"],
] as const;
type Status = Exclude<(typeof statuses)[number][0], "">;
export default async function ConsoleProducts({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
await requireConsole("/console/products");
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.trim().slice(0, 100) : "";
const status = statuses.find(([s]) => s && s === params.status)?.[0] as Status | undefined;
const all = params.all === "1";
const data = unwrap(
await api.console.products.get({
query: { ...(q ? { q } : {}), ...(status ? { status } : {}), ...(all ? { all: "1" as const } : {}) },
}),
);
const filtered = q !== "" || status !== undefined;
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">
{data.all ? "Everyone's products, drafts and removed ones included." : "Your products, drafts included."}
</PageHeader>
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product
</Link>
</div>
<ConsoleNav />
<form action="/console/products" className="mb-6 flex flex-wrap gap-2">
<Input
name="q"
defaultValue={q}
placeholder={data.all ? "Name, ID or creator" : "Name or ID"}
aria-label="Search"
className="max-w-xs"
/>
<NativeSelect name="status" defaultValue={status ?? ""} aria-label="Status" className="w-40">
{statuses.map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
{data.moderator && (
<NativeSelect name="all" defaultValue={all ? "1" : ""} aria-label="Whose" className="w-40">
<NativeSelectOption value="">Yours</NativeSelectOption>
<NativeSelectOption value="1">Everyone&apos;s</NativeSelectOption>
</NativeSelect>
)}
<Button type="submit">Filter</Button>
{(filtered || all) && (
<Link href="/console/products" className={buttonVariants({ variant: "outline" })}>
Clear
</Link>
)}
</form>
<Card>
<CardContent>
{data.products.length === 0 ? (
<p className="text-sm text-muted-foreground">{filtered ? "Nothing matches." : "None yet."}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{data.all && <TableHead>Creator</TableHead>}
<TableHead>State</TableHead>
<TableHead className="text-right">Libraries</TableHead>
<TableHead>Changed</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{data.products.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
{p.name}
</Link>
</TableCell>
{data.all && <TableCell className="text-muted-foreground">{p.creator}</TableCell>}
<TableCell>
<span className="flex flex-wrap gap-1">
<StateTags product={p} />
<RatingTag rating={p.rating} />
</span>
</TableCell>
<TableCell className="text-right tabular-nums">{p.libraries}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={p.updatedAt} dateOnly />
</TableCell>
<TableCell className="text-right">
{!p.removed && (
<Link href={`/products/${p.id}/edit`} className={buttonVariants({ size: "sm", variant: "outline" })}>
{p.status === "draft" ? "Review" : "Edit"}
</Link>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
);
}
+234 -23
View File
@@ -2,44 +2,95 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms"; import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status"; import { StateTags } from "@/components/products";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { UploadCard } from "@/components/upload-card"; import { UploadCard } from "@/components/upload-card";
import { getMe } from "@/lib/api"; import { api, getMe, unwrap, type InfoEntry } from "@/lib/api";
import { saveProduct } from "../../actions"; import { publishProduct, revertInfo, saveLocks, saveProduct } from "../../actions";
import { loadProduct } from "../../load"; import { loadProduct } from "../../load";
export const metadata: Metadata = { title: "Edit product" }; export const metadata: Metadata = { title: "Edit product" };
const fieldNames: Record<string, string> = {
name: "Name",
description: "Description",
rating: "Rating",
tags: "Tags",
listed: "Listed",
locked: "Locks",
status: "Status",
};
export default async function EditProduct({ params }: { params: Promise<{ id: string }> }) { export default async function EditProduct({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const me = await getMe(); const me = await getMe();
if (!me) redirect(`/signin?next=/products/${id}/edit`); if (!me) redirect(`/signin?next=/products/${id}/edit`);
const { product, mine } = await loadProduct(id); const { product, mine, permissions } = await loadProduct(id);
if (!mine) notFound(); const can = (p: string) => (permissions as string[]).includes(p);
if (!can("product:edit")) notFound();
if (mine && product.deleted) redirect(`/products/${id}`);
const { entries } = unwrap(await api.products({ id }).history.get());
const editLocked = can("product:edit-locked");
const locked = (f: string) => product.locked.includes(f) && !editLocked;
const draft = product.status === "draft";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader title={`Edit ${product.name}`}> <PageHeader title={draft ? `Review ${product.name}` : `Edit ${product.name}`}>
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline"> <span className="flex flex-wrap items-center gap-2">
Back to the product <StateTags product={product} />
</Link> <Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
{draft ? "Preview its page" : "Back to the product"}
</Link>
</span>
</PageHeader> </PageHeader>
{draft && (
<Card>
<CardHeader>
<CardTitle>Draft</CardTitle>
<CardDescription>
Only you and moderators see it. Check its info below, then publish it so people can get it. Drafts not
published within 30 days are deleted.
</CardDescription>
</CardHeader>
{can("product:publish") && (
<CardContent>
<ActionForm action={publishProduct} fields={{ id }}>
<SubmitButton pendingLabel="Publishing…">Publish</SubmitButton>
</ActionForm>
</CardContent>
)}
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Info</CardTitle> <CardTitle>Info</CardTitle>
{!mine && <CardDescription>You&apos;re editing someone else&apos;s product: they see your reason.</CardDescription>}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ActionForm action={saveProduct} fields={{ id }}> <ActionForm action={saveProduct} fields={{ id }}>
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel htmlFor="name">Name</FieldLabel> <FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={product.name} required maxLength={64} className="max-w-md" /> <Input
id="name"
name="name"
defaultValue={product.name}
required
maxLength={64}
disabled={locked("name")}
className="max-w-md"
/>
{locked("name") && <LockedNote />}
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="description">Description</FieldLabel> <FieldLabel htmlFor="description">Description</FieldLabel>
@@ -49,22 +100,69 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
defaultValue={product.description} defaultValue={product.description}
maxLength={20000} maxLength={20000}
rows={8} rows={8}
disabled={locked("description")}
className="font-mono text-xs md:text-xs" className="font-mono text-xs md:text-xs"
/> />
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription> {locked("description") ? (
<LockedNote />
) : (
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
)}
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel> <FieldLabel htmlFor="tags">Tags</FieldLabel>
{product.rating === "adult" ? ( <Input
<p className="text-sm">Adult</p> id="tags"
) : ( name="tags"
<NativeSelect id="rating" name="rating" defaultValue="general" className="w-48"> defaultValue={product.tags.join(", ")}
<NativeSelectOption value="general">General</NativeSelectOption> maxLength={300}
<NativeSelectOption value="adult">Adult</NativeSelectOption> disabled={locked("tags")}
</NativeSelect> placeholder="formal, wool, winter"
)} className="max-w-md"
<FieldDescription>Raising it to adult is immediate; lowering it needs review.</FieldDescription> />
{locked("tags") ? <LockedNote /> : <FieldDescription>Up to 10, separated by commas.</FieldDescription>}
</Field> </Field>
<div className="grid gap-6 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
{locked("rating") || (product.rating === "adult" && !can("product:rate-lower")) ? (
<p className="text-sm capitalize">{product.rating}</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue={product.rating} className="w-48">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult">Adult</NativeSelectOption>
</NativeSelect>
)}
{locked("rating") ? (
<LockedNote />
) : (
<FieldDescription>Raising it to adult is immediate; lowering it needs a moderator.</FieldDescription>
)}
</Field>
<Field>
<FieldLabel htmlFor="listed">Listing</FieldLabel>
{locked("listed") ? (
<p className="text-sm">{product.listed ? "Listed" : "Unlisted"}</p>
) : (
<NativeSelect id="listed" name="listed" defaultValue={product.listed ? "yes" : "no"} className="w-48">
<NativeSelectOption value="yes">Listed</NativeSelectOption>
<NativeSelectOption value="no">Unlisted</NativeSelectOption>
</NativeSelect>
)}
{locked("listed") ? (
<LockedNote />
) : (
<FieldDescription>Unlisted: left out of search and lists; its link still works.</FieldDescription>
)}
</Field>
</div>
{!mine && (
<Field>
<FieldLabel htmlFor="reason">Reason</FieldLabel>
<Input id="reason" name="reason" required maxLength={500} className="max-w-md" />
<FieldDescription>Shown to the creator in the product&apos;s history.</FieldDescription>
</Field>
)}
<div> <div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton> <SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div> </div>
@@ -73,7 +171,120 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
</CardContent> </CardContent>
</Card> </Card>
<UploadCard title="Upload a new revision" product={id} /> {can("product:lock") && (
<Card>
<CardHeader>
<CardTitle>Locks</CardTitle>
<CardDescription>Locked fields can only be changed by moderators.</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={saveLocks} fields={{ id }}>
<div className="flex flex-wrap gap-4">
{(["name", "description", "rating", "tags", "listed"] as const).map((f) => (
<label key={f} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="locked"
value={f}
defaultChecked={product.locked.includes(f)}
className="size-4 accent-foreground"
/>
{fieldNames[f]}
</label>
))}
</div>
<div className="flex flex-wrap gap-2">
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
<SubmitButton variant="outline" pendingLabel="Saving…">
Save locks
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
<CardDescription>Every change to its info, with who made it and why.</CardDescription>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">No changes yet.</p>
) : (
<ul className="divide-y">
{entries.map((e) => (
<HistoryRow key={e.id} id={id} entry={e} mine={mine} canRevert={can("product:edit")} />
))}
</ul>
)}
</CardContent>
</Card>
{mine && <UploadCard title="Upload a new revision" product={id} />}
</div> </div>
); );
} }
function LockedNote() {
return <FieldDescription>Locked by a moderator.</FieldDescription>;
}
/** A value as the history shows it. */
function shown(field: string, v: unknown): string {
if (field === "listed") return v ? "listed" : "unlisted";
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
if (typeof v === "string") return v || "empty";
return String(v);
}
function HistoryRow({ id, entry: e, mine, canRevert }: { id: string; entry: InfoEntry; mine: boolean; canRevert: boolean }) {
const what =
e.kind === "publish" ? "published it" : e.kind === "lock" ? "changed the locks" : e.kind === "revert" ? "undid a change" : "edited";
return (
<li className="space-y-2 py-4 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{e.by || "Someone"}</span>
{!e.byCreator && <Tag>Moderator</Tag>}
<span className="text-muted-foreground">{what}</span>
<span className="text-muted-foreground">
<DateTime iso={e.at} />
</span>
</div>
{e.reason && <p className="text-sm">Reason: {e.reason}</p>}
{e.kind !== "publish" && (
<ul className="space-y-1 text-sm text-muted-foreground">
{Object.entries(e.changes).map(([field, c]) => (
<li key={field}>
<span className="text-foreground">{fieldNames[field] ?? field}</span>
{field === "description" ? (
<details className="mt-1">
<summary className="cursor-pointer hover:text-foreground">Before and after</summary>
<pre className="mt-1 max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
{shown(field, c.from)}
</pre>
<pre className="mt-1 max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
{shown(field, c.to)}
</pre>
</details>
) : (
<>
: {shown(field, c.from)} → {shown(field, c.to)}
</>
)}
</li>
))}
</ul>
)}
{canRevert && (e.kind === "edit" || e.kind === "revert") && (
<ActionForm action={revertInfo} fields={{ id, entry: e.id }} className="flex flex-wrap items-center gap-2 space-y-0">
{!mine && <Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="h-8 max-w-xs" />}
<SubmitButton size="sm" variant="outline" pendingLabel="Undoing…">
Undo
</SubmitButton>
</ActionForm>
)}
</li>
);
}
+42 -7
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms"; import { ActionForm, SubmitButton } from "@/components/forms";
import { formatSize, ProductImage, RatingTag } from "@/components/products"; import { formatSize, ProductImage, RatingTag, StateTags } from "@/components/products";
import { PageHeader, Tag } from "@/components/status"; import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time"; import { DateTime } from "@/components/time";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
@@ -19,8 +19,9 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const [me, data] = await Promise.all([getMe(), loadProduct(id)]); const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
const { product, revisions, inLibrary, stayOn, mine, available, catalogs } = data; const { product, revisions, inLibrary, stayOn, mine, available, catalogs, permissions } = data;
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined; const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
const draft = product.status === "draft";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -28,10 +29,30 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
{product.creator && <span>by {product.creator}</span>} {product.creator && <span>by {product.creator}</span>}
<RatingTag rating={product.rating} /> <RatingTag rating={product.rating} />
{product.deleted && <Tag tone="danger">deleted</Tag>} {/* Its state (draft, unlisted, locks) is for those who manage it; anyone may see that it's deleted. */}
{permissions.includes("product:edit") ? (
<StateTags product={product} />
) : (
product.deleted && <Tag tone="danger">Deleted</Tag>
)}
</span> </span>
</PageHeader> </PageHeader>
{draft && (
<p className="text-sm text-muted-foreground">
A draft: only {mine ? "you" : "its creator"} and moderators see it, and nobody can get it until it&apos;s
published.{" "}
{permissions.includes("product:publish") && (
<Link href={`/products/${id}/edit`} className="font-medium text-foreground underline-offset-4 hover:underline">
Review and publish
</Link>
)}
</p>
)}
{!draft && !product.listed && mine && (
<p className="text-sm text-muted-foreground">Unlisted: it&apos;s not in search or lists, but anyone with its link can get it.</p>
)}
{product.deleted && ( {product.deleted && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{mine ? "You deleted this product." : "Its creator deleted this product."} People who have it keep it. {mine ? "You deleted this product." : "Its creator deleted this product."} People who have it keep it.
@@ -51,8 +72,20 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
</div> </div>
)} )}
{product.tags.length > 0 && (
<ul className="flex flex-wrap gap-1.5" aria-label="Tags">
{product.tags.map((t) => (
<li key={t}>
<Link href={`/products?tag=${encodeURIComponent(t)}`} className="hover:opacity-80">
<Tag>{t}</Tag>
</Link>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{product.deleted && !inLibrary ? null : me ? ( {draft || (product.deleted && !inLibrary) ? null : me ? (
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2"> <ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{inLibrary ? ( {inLibrary ? (
@@ -88,11 +121,13 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
Sign in to get it Sign in to get it
</Link> </Link>
)} )}
{permissions.includes("product:edit") && !(mine && product.deleted) && (
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
{mine ? "Edit" : "Edit info"}
</Link>
)}
{mine && !product.deleted && ( {mine && !product.deleted && (
<> <>
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
Edit
</Link>
<ActionForm action={deleteProduct} fields={{ id }}> <ActionForm action={deleteProduct} fields={{ id }}>
<SubmitButton <SubmitButton
variant="outline" variant="outline"
+61 -5
View File
@@ -64,24 +64,80 @@ export async function retract(_: ActionState, form: FormData): Promise<ActionSta
}); });
} }
/** Saves a product's info. Fields left out of the form (locked ones are disabled) aren't changed. */
export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> { export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id"); const id = field(form, "id");
const has = (name: string) => form.has(name);
const rating = field(form, "rating"); const rating = field(form, "rating");
const state = await attempt(async () => { const listed = field(form, "listed");
unwrap( return attempt(async () => {
const { fields } = unwrap(
await api.products({ id }).patch({ await api.products({ id }).patch({
name: field(form, "name"), ...(has("name") ? { name: field(form, "name") } : {}),
description: typeof form.get("description") === "string" ? String(form.get("description")) : "", ...(has("description") ? { description: String(form.get("description") ?? "") } : {}),
...(rating === "adult" ? { rating: "adult" as const } : {}), ...(rating === "adult" || rating === "general" ? { rating } : {}),
...(has("tags")
? {
tags: field(form, "tags")
.split(",")
.map((t) => t.trim())
.filter(Boolean),
}
: {}),
...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}),
...(field(form, "reason") ? { reason: field(form, "reason") } : {}),
}), }),
); );
done(id); done(id);
revalidatePath("/products"); revalidatePath("/products");
revalidatePath(`/products/${id}/edit`);
return fields.length ? "Saved." : "Nothing changed.";
});
}
export async function publishProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).publish.post());
done(id);
revalidatePath("/products");
revalidatePath("/console/products");
}); });
if (state?.error) return state; if (state?.error) return state;
redirect(`/products/${id}`); redirect(`/products/${id}`);
} }
/** Moderators lock fields: the checked ones are locked, the rest unlocked. */
export async function saveLocks(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const fields = form.getAll("locked").filter((v): v is "name" | "description" | "rating" | "tags" | "listed" =>
["name", "description", "rating", "tags", "listed"].includes(String(v)),
);
return attempt(async () => {
unwrap(await api.products({ id }).locks.put({ fields, reason: field(form, "reason") }));
done(id);
revalidatePath(`/products/${id}/edit`);
return "Locks saved.";
});
}
/** Puts back what a history entry changed. */
export async function revertInfo(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const reason = field(form, "reason");
unwrap(
await api
.products({ id })
.history({ entry: Number(field(form, "entry")) })
.revert.post(reason ? { reason } : {}),
);
done(id);
revalidatePath(`/products/${id}/edit`);
return "Undone.";
});
}
/** An upload token and the catalog's upload address, for the browser to send the file there itself. */ /** An upload token and the catalog's upload address, for the browser to send the file there itself. */
export async function startUpload( export async function startUpload(
catalog: string, catalog: string,
+1 -1
View File
@@ -13,7 +13,7 @@ export default async function NewProduct() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader title="New product"> <PageHeader title="New product">
Upload it to a catalog. You can edit its info after.{" "} Upload it to a catalog. Then check its info and publish it.{" "}
<Link href="/products" className="underline-offset-4 hover:underline"> <Link href="/products" className="underline-offset-4 hover:underline">
Back to products Back to products
</Link> </Link>
+10 -4
View File
@@ -11,21 +11,26 @@ export const metadata: Metadata = { title: "Products" };
export default async function Products({ export default async function Products({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ q?: string | string[]; creator?: string | string[] }>; searchParams: Promise<{ q?: string | string[]; creator?: string | string[]; tag?: string | string[] }>;
}) { }) {
const params = await searchParams; const params = await searchParams;
const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : ""); const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
const q = one(params.q, 100); const q = one(params.q, 100);
const creator = one(params.creator, 64); const creator = one(params.creator, 64);
const tag = one(params.tag, 24);
const [me, { products }] = await Promise.all([ const [me, { products }] = await Promise.all([
getMe(), getMe(),
api.products.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}) } }).then(unwrap), api.products
.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}), ...(tag ? { tag } : {}) } })
.then(unwrap),
]); ]);
return ( return (
<> <>
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">{creator ? `By ${creator}.` : "Community products for T5DE."}</PageHeader> <PageHeader title="Products">
{[creator && `By ${creator}.`, tag && `Tagged “${tag}”.`].filter(Boolean).join(" ") || "Community products for T5DE."}
</PageHeader>
{me && ( {me && (
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}> <Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product New product
@@ -35,12 +40,13 @@ export default async function Products({
<form action="/products" className="mb-6 flex w-full max-w-md gap-2"> <form action="/products" className="mb-6 flex w-full max-w-md gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" /> <Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
{creator && <input type="hidden" name="creator" value={creator} />} {creator && <input type="hidden" name="creator" value={creator} />}
{tag && <input type="hidden" name="tag" value={tag} />}
<Button type="submit" variant="outline"> <Button type="submit" variant="outline">
Search Search
</Button> </Button>
</form> </form>
{products.length === 0 ? ( {products.length === 0 ? (
<Empty>{q ? "Nothing found." : "No products yet."}</Empty> <Empty>{q || tag || creator ? "Nothing found." : "No products yet."}</Empty>
) : ( ) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => ( {products.map((p) => (
+22 -2
View File
@@ -8,6 +8,23 @@ export function RatingTag({ rating }: { rating: string }) {
return rating === "adult" ? <Tag tone="adult">Adult</Tag> : null; return rating === "adult" ? <Tag tone="adult">Adult</Tag> : null;
} }
/** A product's state, where it isn't plainly published and listed: draft, unlisted, deleted, removed, locked. */
export function StateTags({
product: p,
}: {
product: Pick<ProductSummary, "status" | "listed" | "deleted" | "removed" | "locked">;
}) {
return (
<>
{p.status === "draft" && <Tag tone="strong">Draft</Tag>}
{p.status === "published" && !p.listed && <Tag>Unlisted</Tag>}
{p.deleted && <Tag tone="danger">Deleted</Tag>}
{p.removed && <Tag tone="danger">Removed</Tag>}
{p.locked.length > 0 && <Tag>Locked: {p.locked.join(", ")}</Tag>}
</>
);
}
/** 1536 → "1.5 KB". */ /** 1536 → "1.5 KB". */
export function formatSize(bytes: number): string { export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -39,7 +56,7 @@ export function ProductCard({
product, product,
children, children,
}: { }: {
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest" | "image" | "categories">; product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest" | "image" | "categories" | "listed">;
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
const category = product.categories.at(-1)?.name; const category = product.categories.at(-1)?.name;
@@ -51,7 +68,10 @@ export function ProductCard({
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline"> <Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
{product.name} {product.name}
</Link> </Link>
<RatingTag rating={product.rating} /> <span className="flex gap-1">
{!product.listed && <Tag>Unlisted</Tag>}
<RatingTag rating={product.rating} />
</span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")} {[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")}
+2 -1
View File
@@ -53,7 +53,8 @@ export function UploadForm({ catalogs, product }: { catalogs: Option[]; product?
const answer = await send(start.url, start.token, body, setProgress); const answer = await send(start.url, start.token, body, setProgress);
const value = answer.body as { product?: unknown; message?: unknown } | null; const value = answer.body as { product?: unknown; message?: unknown } | null;
if (answer.status === 201 && typeof value?.product === "string") { if (answer.status === 201 && typeof value?.product === "string") {
router.push(`/products/${value.product}`); // A new product is a draft: on to reviewing its info and publishing it.
router.push(product ? `/products/${value.product}` : `/products/${value.product}/edit`);
router.refresh(); router.refresh();
return; return;
} }
+2
View File
@@ -49,6 +49,8 @@ export type CatalogTeam = Data<ReturnType<V1["catalogs"]>["team"]["get"]>;
export type ProductSummary = Data<V1["products"]["get"]>["products"][number]; export type ProductSummary = Data<V1["products"]["get"]>["products"][number];
export type ProductPage = Data<ReturnType<V1["products"]>["get"]>; export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
export type Revision = ProductPage["revisions"][number]; export type Revision = ProductPage["revisions"][number];
export type InfoEntry = Data<ReturnType<V1["products"]>["history"]["get"]>["entries"][number];
export type ManagedProduct = Data<V1["console"]["products"]["get"]>["products"][number];
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number]; export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number]; export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number]; export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number];
+6 -4
View File
@@ -87,9 +87,10 @@ sha256 (hex) of `contents` as JSON.
## Products, catalogs and libraries ## Products, catalogs and libraries
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator, The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
creatorUsername, parent, image, categories, createdAt, updatedAt}`; `parent`, `image` and `categories` come from the creatorUsername, parent, image, categories, status, listed, tags, locked, deleted, removed, publishedAt, createdAt,
latest revision (its IMVU parent's public info). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU updatedAt}`; `parent`, `image` and `categories` come from the latest revision (its IMVU parent's public info).
category ID) and `limit` (1-200, default 60). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU category ID), `tag`, `catalog` and `limit` (1-200,
default 60).
## Age verification ## Age verification
@@ -110,10 +111,11 @@ Permissions, roles and scopes are in docs/console.md. A missing permission answe
| | | | | |
| --- | --- | | --- | --- |
| `GET /api/v1/console` | (anyone controlling a catalog, product or hosted room, and moderators; else 403) `{sections, mine, todo, moderation, audit}`: `moderation` (`{counts, connections, online}`) only for moderators, `audit` the latest 20 entries they may see | | `GET /api/v1/console` | (anyone controlling a catalog, product or hosted room, and moderators; else 403) `{sections, mine, todo, moderation, audit}`: `todo` counts what waits on them (`drafts`, `memberRequests`; for moderators `pendingCatalogs`, `waitingPeople`), `moderation` (`{counts, connections, online}`) only for moderators, `audit` the latest 20 entries they may see |
| `GET /api/v1/console/audit?catalog=&product=&room=&person=&actor=&action=&q=&from=&to=&before=&limit=` | `{entries, before}`: entries the person may see (docs/console.md), newest first; `action` matches a prefix, `from`/`to` are days, `before` an entry ID for the next page. Entries are `{id, at, actor, action, target, tags, tagNames, detail}` | | `GET /api/v1/console/audit?catalog=&product=&room=&person=&actor=&action=&q=&from=&to=&before=&limit=` | `{entries, before}`: entries the person may see (docs/console.md), newest first; `action` matches a prefix, `from`/`to` are days, `before` an entry ID for the next page. Entries are `{id, at, actor, action, target, tags, tagNames, detail}` |
| `GET /api/v1/console/permissions?catalog=&product=` | `{roles, permissions}` at that scope (the hub if neither) | | `GET /api/v1/console/permissions?catalog=&product=` | `{roles, permissions}` at that scope (the hub if neither) |
| `GET /api/v1/console/catalogs` | `{catalogs, moderator}`: every catalog for moderators, else those on whose team the person is | | `GET /api/v1/console/catalogs` | `{catalogs, moderator}`: every catalog for moderators, else those on whose team the person is |
| `GET /api/v1/console/products?q=&status=&all=1` | `{products, moderator, all}`: the person's own products (with `all=1`, moderators get everyone's), drafts, unlisted, deleted and removed ones included, most recently changed first; each with `libraries` (how many people have it). `status`: `draft`, `published` (and listed), `unlisted`, `locked`, `deleted` or `removed`; `q` matches the name, ID or creator. 403 for people with no products |
Moderators: Moderators:
+2
View File
@@ -473,6 +473,8 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md):
**Done** (2026-11-05), except catalog deletion requests and the hosted rooms' scope, which come with cases **Done** (2026-11-05), except catalog deletion requests and the hosted rooms' scope, which come with cases
(phase 3). Built-in roles are code (`apps/api/src/permissions.ts`); the `role` table comes with custom roles. (phase 3). Built-in roles are code (`apps/api/src/permissions.ts`); the `role` table comes with custom roles.
2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category. 2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category.
**Done** (2026-11-05), except a product's own category: the hub has no list of IMVU's categories to choose from,
so products are filed under their parent's category path for now (later: IMVU's category tree, cached).
3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens; 3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens;
two-factor sign-in for sensitive permissions. two-factor sign-in for sensitive permissions.
4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike). 4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike).
+9 -7
View File
@@ -107,12 +107,13 @@ catalog's). Only approved catalogs with imports on and not paused.
| `DELETE /api/v1/catalogs/:id/products/:product` | (`hosting:delist`) `{reason}`: the catalog stops hosting every revision of it; it's in the changes feed. The product stays available from other catalogs hosting it. | | `DELETE /api/v1/catalogs/:id/products/:product` | (`hosting:delist`) `{reason}`: the catalog stops hosting every revision of it; it's in the changes feed. The product stays available from other catalogs hosting it. |
| `POST /api/v1/catalogs/:id/suspend`, `.../reinstate` | (`catalog:suspend`) `{reason}`: a suspended catalog's products aren't available from it, it can't register revisions, and its team sees why | | `POST /api/v1/catalogs/:id/suspend`, `.../reinstate` | (`catalog:suspend`) `{reason}`: a suspended catalog's products aren't available from it, it can't register revisions, and its team sees why |
| `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL (the catalog's upload policy and rating ceiling apply) | | `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL (the catalog's upload policy and rating ceiling apply) |
| `GET /api/v1/products?q=&creator=&category=&catalog=&tag=&limit=` | (anyone) `{products}`, newest first: published and listed; adult ones only for people with an adult account. `category`: an IMVU category ID; the product's own category if set, else its latest revision's parent's category path (e.g. 106 Clothing, 128 Tops). `limit`: 1-200, default 60 | | `GET /api/v1/products?q=&creator=&category=&catalog=&tag=&limit=` | (anyone) `{products}`, newest first: published and listed (and the person's own unlisted ones); adult ones only for people with an adult account. `category`: an IMVU category ID in its latest revision's parent's category path (e.g. 106 Clothing, 128 Tops). `tag`: one tag; `catalog`: those the catalog hosts. `limit`: 1-200, default 60 |
| `GET /api/v1/products/:id` | (anyone, adult rule as above; drafts only for their creator and moderators) `{product, revisions, inLibrary?, mine?, available, permissions}` | | `GET /api/v1/products/:id` | (anyone, adult rule as above; drafts only for their creator and moderators) `{product, revisions, inLibrary?, mine?, available, permissions}` |
| `PATCH /api/v1/products/:id` | (`product:edit`) `{name?, description?, rating?, tags?, category?, listed?, reason?}`; description is HTML, cleaned by the allowlist; rating may go general → adult; adult → general opens a `rating-review` case unless the person has `product:rate-lower`. `reason` is required when the person isn't the creator, and shown to them. Locked fields need `product:edit-locked`. Each change is kept in the info history. | | `PATCH /api/v1/products/:id` | (`product:edit`) `{name?, description?, rating?, tags?, listed?, reason?}` → `{product, fields}` (the fields that changed; only those go in the history). Description is HTML, cleaned by the allowlist. Tags: up to 10, lowercased, each up to 24 letters, digits, spaces or dashes. Rating may go general → adult; adult → general needs `product:rate-lower` (400 `review_needed`; with cases it opens a `rating-review` case instead), and no revision that isn't retracted may be rated adult (400 `files_adult`). `reason` is required when the person isn't the creator (400 `reason_needed`), and shown to them. Locked fields need `product:edit-locked` (403 `locked`). The creator can't edit a product they deleted; moderators can. |
| `POST /api/v1/products/:id/publish` | (`product:publish`) a draft becomes published; it needs a name, and a rating no lower than its files declare | | `POST /api/v1/products/:id/publish` | (`product:publish`) a draft becomes published (409 `published` if it is already) |
| `GET /api/v1/products/:id/history` | the info history: `{entries: [{at, by, fields, reason}]}`; `POST .../history/:n/revert` | | `GET /api/v1/products/:id/history` | (`audit:read` at the product: its creator, moderators) the info history, newest first: `{entries: [{id, at, kind ("edit", "revert", "lock", "publish"), by, byCreator, changes: {field: {from, to}}, reason, revertOf}]}` |
| `PUT /api/v1/products/:id/locks` | (`product:lock`) `{fields, reason}` | | `POST /api/v1/products/:id/history/:entry/revert` | (`product:edit`) `{reason?}`: puts back what an edit (or revert) changed, as a new `revert` entry, by the same rules as an edit (locks, reasons, ratings); 409 `unchanged` if the info is already as it was |
| `PUT /api/v1/products/:id/locks` | (`product:lock`) `{fields, reason}`: the fields locked from now on (any of name, description, rating, tags, listed; the rest are unlocked). `reason` required; kept in the history |
| `POST /api/v1/products/:id/transfer` | (`product:transfer`) `{to}`; the other person accepts (`.../transfer/accept`) | | `POST /api/v1/products/:id/transfer` | (`product:transfer`) `{to}`; the other person accepts (`.../transfer/accept`) |
| `POST /api/v1/products/:id/restore` | (`product:restore`) undo a creator's delete | | `POST /api/v1/products/:id/restore` | (`product:restore`) undo a creator's delete |
| `POST /api/v1/products/:id/hosting` | (`hosting:choose`) `{catalog, revision?}`: ask another catalog to mirror it (every revision, or one); the catalog's `mirrors` setting decides (auto, or its team approves) | | `POST /api/v1/products/:id/hosting` | (`hosting:choose`) `{catalog, revision?}`: ask another catalog to mirror it (every revision, or one); the catalog's `mirrors` setting decides (auto, or its team approves) |
@@ -130,8 +131,9 @@ catalog's). Only approved catalogs with imports on and not paused.
The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes. The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes.
A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername, A product (in lists, its page, the library) is `{id, name, description, rating, latest, creator, creatorUsername,
parent, image, categories, status ("draft"/"published"), listed, tags, category, locked, imvuProduct, createdAt, parent, image, categories, status ("draft"/"published"), listed, tags, locked, deleted, removed, publishedAt,
updatedAt}`. `parent`, `image` and `categories` are the latest revision's: imvuProduct, createdAt, updatedAt}` (`imvuProduct` comes with imports). The product's page also has `permissions`: what the
person may do to it (the website shows actions by them). `parent`, `image` and `categories` are the latest revision's:
`parent` is the IMVU product it derives from (0 if none); `image` (an https address on `*.imvu.com`, or null) and `parent` is the IMVU product it derives from (0 if none); `image` (an https address on `*.imvu.com`, or null) and
`categories` (`[{id, name}]`, outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; `[]` if `categories` (`[{id, name}]`, outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; `[]` if
unknown) are that IMVU product's, from IMVU's public API (`GET https://api.imvu.com/product/product-<id>`). unknown) are that IMVU product's, from IMVU's public API (`GET https://api.imvu.com/product/product-<id>`).
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE "product_info_change" (
"id" serial PRIMARY KEY NOT NULL,
"product_id" text NOT NULL,
"by_id" text,
"kind" text NOT NULL,
"changes" jsonb NOT NULL,
"reason" text DEFAULT '' NOT NULL,
"revert_of" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "status" text DEFAULT 'published' NOT NULL;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "listed" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "tags" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "product" ADD COLUMN "locked" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "product_info_change_product" ON "product_info_change" USING btree ("product_id","id");--> statement-breakpoint
CREATE INDEX "product_tags" ON "product" USING gin ("tags");--> statement-breakpoint
-- Products from before drafts were published when made.
UPDATE "product" SET "published_at" = "created_at" WHERE "published_at" IS NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -78,6 +78,13 @@
"when": 1793862017365, "when": 1793862017365,
"tag": "0010_catalog_management", "tag": "0010_catalog_management",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1793884711638,
"tag": "0011_product_info",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
import * as schema from "./schema"; import * as schema from "./schema";
export { schema }; export { schema };
export type { AuditTags, DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema"; export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem } from "./schema";
export * from "drizzle-orm"; export * from "drizzle-orm";
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
+34 -1
View File
@@ -359,6 +359,14 @@ export const product = pgTable(
description: text("description").notNull().default(""), // HTML, already cleaned description: text("description").notNull().default(""), // HTML, already cleaned
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
latest: integer("latest").notNull().default(0), // the newest revision's number latest: integer("latest").notNull().default(0), // the newest revision's number
// "draft" (new uploads: only its creator and moderators see it) or "published".
status: text("status").notNull().default("published"),
publishedAt: timestamp("published_at", { withTimezone: true }),
// Unlisted: left out of search, lists and creator pages for others; its link still works.
listed: boolean("listed").notNull().default(true),
tags: jsonb("tags").$type<string[]>().notNull().default([]),
// Info fields a moderator locked: only people with product:edit-locked change them.
locked: jsonb("locked").$type<string[]>().notNull().default([]),
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it. // Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.
deletedAt: timestamp("deleted_at", { withTimezone: true }), deletedAt: timestamp("deleted_at", { withTimezone: true }),
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms). // Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
@@ -367,7 +375,32 @@ export const product = pgTable(
createdAt: createdAt(), createdAt: createdAt(),
updatedAt: updatedAt(), updatedAt: updatedAt(),
}, },
(t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)], (t) => [
index("product_creator").on(t.creatorId),
index("product_created").on(t.createdAt),
index("product_tags").using("gin", t.tags),
],
);
/** One field's change in a product's info history. */
export type InfoChange = { from: unknown; to: unknown };
// A product's info history: every edit, revert, lock and publish, with who and why (docs/products.md).
export const productInfoChange = pgTable(
"product_info_change",
{
id: serial("id").primaryKey(),
productId: text("product_id")
.notNull()
.references(() => product.id, { onDelete: "cascade" }),
byId: text("by_id").references(() => user.id, { onDelete: "set null" }),
kind: text("kind").notNull(), // "edit", "revert", "lock", "publish"
changes: jsonb("changes").$type<Record<string, InfoChange>>().notNull(),
reason: text("reason").notNull().default(""),
revertOf: integer("revert_of"), // the entry a revert undid
createdAt: createdAt(),
},
(t) => [index("product_info_change_product").on(t.productId, t.id)],
); );
// A revision: a product's files. Never changes (but can be retracted). // A revision: a product's files. Never changes (but can be retracted).