feat: product drafts, moderator edits with reasons and locks, info history, unlisted products and tags
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m4s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're
deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and
publishes it.

Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the
creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings
(product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why,
and can be undone by the same rules.

Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and
catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do.

A product's own category is left for later: the hub has no list of IMVU's categories to pick from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 05:40:43 -08:00
1 parent 2cbedd26fc
commit 8793b5afd8
24 files changed
+4095 -85

No files matched your search

+167 -8
View File
@@ -26,7 +26,7 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { availableTo, ProductError, Products } from "./products";
import { availableTo, ProductError, Products, type EditRights, type ProductViewer } from "./products";
import {
chatId,
HostedError,
@@ -333,11 +333,35 @@ export function createApp({
parent: latest?.parent ?? 0,
image: latest?.parentInfo?.image ?? null,
categories: latest?.parentInfo?.categories ?? [],
status: p.status as "draft" | "published",
listed: p.listed,
tags: p.tags,
locked: p.locked,
deleted: p.deletedAt !== null,
removed: p.removedAt !== null,
publishedAt: p.publishedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
});
const productViewer = (p: Person | undefined): ProductViewer | undefined =>
p ? { id: p.id, moderator: isModerator(p) } : undefined;
const productScope = (id: string): Scope => ({ type: "product", id });
/** What someone editing a product may do beyond product:edit. */
const editRights = async (me: Person, id: string, creatorId: string): Promise<EditRights> => ({
creator: me.id === creatorId,
editLocked: await perms.can(me, "product:edit-locked", productScope(id)),
rateLower: await perms.can(me, "product:rate-lower", productScope(id)),
});
/** A product's creator (for its audit tags and the person's rights), or 404. */
const creatorOf = async (id: string) => {
const [p] = await db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, id));
if (!p) throw new Problem(404, "not_found", "No such product.");
return p.creatorId;
};
const catalogView = (c: typeof schema.catalog.$inferSelect) => ({
id: c.id,
name: c.name,
@@ -1603,7 +1627,7 @@ export function createApp({
"/products",
async ({ person, query }) => {
const adult = await personAdult(person);
const rows = await products.list({ ...query, adult });
const rows = await products.list({ ...query, adult, viewerId: person?.id });
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername, r)) };
},
{
@@ -1611,6 +1635,8 @@ export function createApp({
q: t.Optional(t.String({ maxLength: 100 })),
creator: t.Optional(t.String({ maxLength: 64 })),
category: t.Optional(t.Numeric({ minimum: 1, maximum: 2_147_483_647, multipleOf: 1 })),
tag: t.Optional(t.String({ maxLength: 24 })),
catalog: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
}),
},
@@ -1619,7 +1645,7 @@ export function createApp({
"/products/:id",
async ({ person, params }) => {
const adult = await personAdult(person);
const p = await productCall(() => products.get(params.id, adult));
const p = await productCall(() => products.get(params.id, adult, productViewer(person)));
const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
const latest = p.revisions.find((r) => r.number === p.product.latest);
return {
@@ -1637,6 +1663,7 @@ export function createApp({
inLibrary: lib !== undefined,
stayOn: lib?.stayOn ?? null,
mine: person?.id === p.product.creatorId,
permissions: person ? [...(await perms.at(person, productScope(p.product.id)))] : [],
available: person ? await products.available(person.id, p.product.id) : false,
catalogs: await Promise.all(
(await products.hostingCatalogs(p.product.id)).map(async (c) => ({
@@ -1652,9 +1679,21 @@ export function createApp({
.patch(
"/products/:id",
async ({ me, params, body }) => {
const p = await productCall(() => products.update(me.id, params.id, body));
await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) });
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
const creatorId = await creatorOf(params.id);
await need(me, "product:edit", productScope(params.id));
const { reason = "", ...input } = body;
const done = await productCall(async () =>
products.updateInfo(me.id, params.id, input, reason, await editRights(me, params.id, creatorId)),
);
if (done.entry)
await audited(me.id, "product.edit", params.id, { fields: done.fields, ...(reason ? { reason } : {}) }, {
product: params.id,
person: creatorId,
});
return {
product: productView(done.product, undefined, undefined, await products.latestParent(done.product)),
fields: done.fields,
};
},
{
signedIn: true,
@@ -1663,6 +1702,95 @@ export function createApp({
name: t.Optional(t.String({ maxLength: 64 })),
description: t.Optional(t.String({ maxLength: 20000 })),
rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])),
tags: t.Optional(t.Array(t.String({ maxLength: 64 }), { maxItems: 20 })),
listed: t.Optional(t.Boolean()),
reason: t.Optional(t.String({ maxLength: 500 })),
}),
},
)
// A draft becomes published.
.post(
"/products/:id/publish",
async ({ me, params }) => {
const creatorId = await creatorOf(params.id);
await need(me, "product:publish", productScope(params.id));
const p = await productCall(() => products.publish(me.id, params.id));
await audited(me.id, "product.publish", p.id, { name: p.name }, { product: p.id, person: creatorId });
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
},
{ signedIn: true, params: productParam },
)
// The info history: who changed what, and why.
.get(
"/products/:id/history",
async ({ me, params }) => {
const creatorId = await creatorOf(params.id);
await need(me, "audit:read", productScope(params.id));
const entries = await products.history(params.id);
return {
entries: entries.map((e) => ({
id: e.id,
at: e.at.toISOString(),
kind: e.kind as "edit" | "revert" | "lock" | "publish",
by: e.by ?? "",
byCreator: e.byId === creatorId,
changes: e.changes,
reason: e.reason,
revertOf: e.revertOf,
})),
};
},
{ signedIn: true, params: productParam },
)
.post(
"/products/:id/history/:entry/revert",
async ({ me, params, body }) => {
const creatorId = await creatorOf(params.id);
await need(me, "product:edit", productScope(params.id));
const reason = body.reason ?? "";
const done = await productCall(async () =>
products.revert(me.id, params.id, params.entry, reason, await editRights(me, params.id, creatorId)),
);
await audited(
me.id,
"product.revert",
params.id,
{ entry: params.entry, fields: done.fields, ...(reason ? { reason } : {}) },
{ product: params.id, person: creatorId },
);
return {
product: productView(done.product, undefined, undefined, await products.latestParent(done.product)),
fields: done.fields,
};
},
{
signedIn: true,
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), entry: t.Numeric({ minimum: 1, multipleOf: 1 }) }),
body: t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) }),
},
)
// Moderators lock info fields, so only people with product:edit-locked change them.
.put(
"/products/:id/locks",
async ({ me, params, body }) => {
const creatorId = await creatorOf(params.id);
await need(me, "product:lock", productScope(params.id));
const p = await productCall(() => products.setLocks(me.id, params.id, body.fields, body.reason));
await audited(me.id, "product.lock", p.id, { fields: p.locked, reason: body.reason.trim() }, {
product: p.id,
person: creatorId,
});
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
},
{
signedIn: true,
params: productParam,
body: t.Object({
fields: t.Array(
t.Union([t.Literal("name"), t.Literal("description"), t.Literal("rating"), t.Literal("tags"), t.Literal("listed")]),
{ maxItems: 5 },
),
reason: t.String({ maxLength: 500 }),
}),
},
)
@@ -1699,7 +1827,9 @@ export function createApp({
.get(
"/products/:id/files",
async ({ person, params }) => {
const r = await productCall(async () => products.revisionFor(person?.id, params.id, await personAdult(person)));
const r = await productCall(async () =>
products.revisionFor(person?.id, params.id, await personAdult(person), productViewer(person)),
);
return {
product: r.product.id,
revision: r.revision.number,
@@ -1738,7 +1868,7 @@ export function createApp({
"/library/:id",
async ({ me, params }) => {
try {
await products.add(me.id, params.id, await personAdult(me));
await products.add(me.id, params.id, await personAdult(me), productViewer(me));
} catch (e) {
if (e instanceof ProductError && e.slug === "members_only")
throw new Problem(e.status, e.slug, e.message, { catalogs: await products.hostingCatalogs(params.id) });
@@ -1868,6 +1998,34 @@ export function createApp({
)
// The console.
.get(
"/console/products",
async ({ me, query }) => {
const mod = isModerator(me);
const all = mod && query.all === "1";
const rows = await products.manage({ creatorId: all ? undefined : me.id, q: query.q?.trim(), status: query.status });
if (!mod && rows.length === 0 && !query.q && !query.status && (await perms.ownProducts(me.id)).length === 0)
throw new Problem(403, "forbidden", "You haven't made any products.");
return {
products: rows.map((r) => ({
...productView(r.product, r.creator, r.creatorUsername, r),
libraries: r.libraries,
})),
moderator: mod,
all,
};
},
{
signedIn: true,
query: t.Object({
q: t.Optional(t.String({ maxLength: 100 })),
status: t.Optional(
t.Union(["draft", "published", "unlisted", "deleted", "removed", "locked"].map((s) => t.Literal(s))),
),
all: t.Optional(t.Literal("1")),
}),
},
)
.get(
"/console/catalogs",
async ({ me }) => {
@@ -1929,6 +2087,7 @@ export function createApp({
sections,
mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length },
todo: {
drafts: scope.products.length ? await products.draftCount(me.id) : 0,
memberRequests: requests?.n ?? 0,
pendingCatalogs: moderation?.counts.pendingCatalogs ?? 0,
waitingPeople: moderation?.counts.waiting ?? 0,
+9 -1
View File
@@ -67,12 +67,20 @@ const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageServic
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their
// parent's info (IMVU was unavailable, or they predate it) get it, starting now.
// parent's info (IMVU was unavailable, or they predate it) get it, starting now; drafts never published go after 30
// days.
const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e));
const purgeDrafts = () =>
products
.purgeDrafts()
.then((n) => n > 0 && console.log(`deleted ${n} drafts never published`))
.catch((e) => console.error("drafts:", e));
parentInfo();
purgeDrafts();
setInterval(() => {
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
parentInfo();
purgeDrafts();
}, 10 * 60 * 1000);
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
+290 -26
View File
@@ -20,12 +20,27 @@ import { cleanHTML, maxDescription } from "./html";
import type { ImvuClient } from "./imvu";
import type { Signer } from "./keys";
const { catalog, catalogMember, product, productRevision, hosting, library, roleAssignment, user } = schema;
const { catalog, catalogChange, catalogMember, product, productInfoChange, productRevision, hosting, library, roleAssignment, user } =
schema;
export type Catalog = typeof catalog.$inferSelect;
export type Product = typeof product.$inferSelect;
export type Revision = typeof productRevision.$inferSelect;
/** Info fields people edit (and moderators lock). */
export const infoFields = ["name", "description", "rating", "tags", "listed"] as const;
export type InfoField = (typeof infoFields)[number];
export type InfoInput = { name?: string; description?: string; rating?: string; tags?: string[]; listed?: boolean };
/** What the person editing may do beyond product:edit (from the permission check). */
export type EditRights = { creator: boolean; editLocked: boolean; rateLower: boolean };
/** Who's looking: drafts are only for their creator and moderators. */
export type ProductViewer = { id: string; moderator: boolean };
/** Drafts never published are deleted after this long. */
export const draftDays = 30;
/** An error for the person; the routes turn it into a Problem. */
export class ProductError extends Error {
constructor(
@@ -50,6 +65,19 @@ const nameRule = (s: unknown, max: number) => {
return v;
};
/** Tags as people type them: lowercase words (letters, digits, spaces, dashes), at most 10, each up to 24. */
export function checkTags(tags: unknown): string[] | undefined {
if (!Array.isArray(tags) || tags.length > 10) return undefined;
const out: string[] = [];
for (const t of tags) {
const v = typeof t === "string" ? t.trim().toLowerCase().replace(/\s+/g, " ") : "";
if (!v) continue;
if (v.length > 24 || !/^[\p{L}\p{N}][\p{L}\p{N} -]*$/u.test(v)) return undefined;
if (!out.includes(v)) out.push(v);
}
return out;
}
/** A file list as catalogs send it: checked and sorted by name. */
export function checkFiles(files: unknown): FileEntry[] | undefined {
if (!Array.isArray(files) || files.length === 0 || files.length > 512) return undefined;
@@ -75,10 +103,10 @@ const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
/**
* Whether a product is available to a person (docs/products.md, "Member"): they made it, or a catalog hosting it is
* approved, hasn't banned them, and is open or has them as a member or on its team.
* Whether a product is available to a person (docs/products.md, "Member"): it's published, and they made it, or a
* catalog hosting it is approved, hasn't banned them, and is open or has them as a member or on its team.
*/
export const availableTo = (personId: string) => sql`(
export const availableTo = (personId: string) => sql`(${product.status} = 'published' and (
${product.creatorId} = ${personId} or exists (
select 1 from ${hosting} h
join ${catalog} c on c.id = h.catalog_id
@@ -87,7 +115,7 @@ export const availableTo = (personId: string) => sql`(
and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId}
or exists (select 1 from ${roleAssignment} r
where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id))
))`;
)))`;
/** Each product's latest revision (joined on product.latest, for lists). */
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
@@ -239,7 +267,7 @@ export class Products {
const name = nameRule(body.name, 64) ?? "Untitled";
const [made] = await tx
.insert(product)
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating })
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating, status: "draft" })
.returning();
p = made!;
}
@@ -265,7 +293,7 @@ export class Products {
const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general";
await tx.update(product).set({ latest: number, rating }).where(eq(product.id, p.id));
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
return { product: p.id, revision: number };
return { product: p.id, revision: number, draft: p.status === "draft" };
});
// The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries).
if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {});
@@ -354,16 +382,36 @@ export class Products {
}
/**
* Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
* Published products, newest first; adult ones only if adult; unlisted ones only for their creator. `category`:
* only those whose latest revision's parent is in that IMVU category (anywhere in its path); `tag`: those with
* the tag; `catalog`: those the catalog hosts. Each comes with its latest revision's parent and parent info.
*/
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)];
async list(opts: {
q?: string;
creator?: string;
category?: number;
tag?: string;
catalog?: string;
adult: boolean;
viewerId?: string;
limit?: number;
}) {
const conds = [
ne(product.latest, 0),
eq(product.status, "published"),
isNull(product.deletedAt),
isNull(product.removedAt),
opts.viewerId ? or(eq(product.listed, true), eq(product.creatorId, opts.viewerId))! : eq(product.listed, true),
];
if (!opts.adult) conds.push(eq(product.rating, "general"));
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`));
if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
if (opts.category !== undefined)
conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`);
const tag = opts.tag !== undefined ? checkTags([opts.tag])?.[0] : undefined;
if (opts.tag !== undefined) conds.push(tag ? sql`${product.tags} @> ${JSON.stringify([tag])}::jsonb` : sql`false`);
if (opts.catalog)
conds.push(sql`exists (select 1 from ${hosting} h where h.product_id = ${product.id} and h.catalog_id = ${opts.catalog})`);
const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
return this.db
.select({
@@ -390,7 +438,7 @@ export class Products {
return r;
}
async get(id: string, adult: boolean) {
async get(id: string, adult: boolean, viewer?: ProductViewer) {
const [row] = await this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
.from(product)
@@ -398,6 +446,8 @@ export class Products {
.where(eq(product.id, id));
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (row.product.status === "draft" && !(viewer?.moderator || viewer?.id === row.product.creatorId))
throw new ProductError(404, "not_found", "No such product.");
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db
.select()
@@ -412,27 +462,240 @@ export class Products {
return { ...row, revisions, hosts };
}
async update(personId: string, id: string, input: { name?: string; description?: string; rating?: string }) {
const p = await this.own(personId, id);
const set: Partial<Product> = {};
// ---- product info: edits, history, locks, publishing ----
/** A product for changing its info: not removed; deleted ones only by others than the creator (moderators). */
private async editable(id: string, rights: EditRights) {
const [p] = await this.db.select().from(product).where(eq(product.id, id));
if (!p || p.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (p.deletedAt && rights.creator) throw new ProductError(410, "deleted", "You deleted this product.");
return p;
}
/**
* Changes a product's info (the route checked product:edit). Only fields that change are kept in its history.
* Someone other than the creator must give a reason; locked fields need editLocked; lowering the rating needs
* rateLower (and files rated general). Returns the product, and the history entry if anything changed.
*/
async updateInfo(
byId: string,
id: string,
input: InfoInput,
reason: string,
rights: EditRights,
revertOf?: number,
): Promise<{ product: Product; entry?: number; fields: InfoField[] }> {
const p = await this.editable(id, rights);
reason = reason.trim().slice(0, 500);
if (!rights.creator && !reason)
throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason with the change.");
const next: Partial<Pick<Product, InfoField>> = {};
if (input.name !== undefined) {
const name = nameRule(input.name, 64);
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
set.name = name;
next.name = name;
}
if (input.description !== undefined) {
if (input.description.length > maxDescription)
throw new ProductError(400, "invalid", "That description is too long.");
set.description = cleanHTML(input.description);
next.description = cleanHTML(input.description);
}
if (input.rating !== undefined) {
if (input.rating === "adult") set.rating = "adult";
else if (input.rating === "general" && p.rating === "adult")
// Lowering is reviewed by moderators first (design.md); until that exists, it's refused.
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
if (input.rating !== "general" && input.rating !== "adult")
throw new ProductError(400, "invalid", "The rating must be general or adult.");
next.rating = input.rating;
}
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
if (input.tags !== undefined) {
const tags = checkTags(input.tags);
if (!tags)
throw new ProductError(400, "invalid", "Up to 10 tags, each up to 24 letters, digits, spaces or dashes.");
next.tags = tags;
}
if (input.listed !== undefined) next.listed = input.listed;
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const changes: Record<string, { from: unknown; to: unknown }> = {};
for (const f of infoFields)
if (next[f] !== undefined && !same(next[f], p[f])) changes[f] = { from: p[f], to: next[f] };
const fields = Object.keys(changes) as InfoField[];
if (fields.length === 0) return { product: p, fields };
const locked = fields.filter((f) => p.locked.includes(f));
if (locked.length && !rights.editLocked)
throw new ProductError(403, "locked", `A moderator locked this product's ${locked.join(", ")}.`);
if (changes.rating?.to === "general") {
if (!rights.rateLower)
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
const [adultFiles] = await this.db
.select({ n: productRevision.number })
.from(productRevision)
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"), eq(productRevision.retracted, false)))
.limit(1);
if (adultFiles)
throw new ProductError(400, "files_adult", `Revision ${adultFiles.n}'s files are rated adult; retract it first.`);
}
return this.db.transaction(async (tx) => {
const [updated] = await tx.update(product).set(next).where(eq(product.id, id)).returning();
const [entry] = await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: revertOf ? "revert" : "edit", changes, reason, revertOf })
.returning({ id: productInfoChange.id });
return { product: updated!, entry: entry!.id, fields };
});
}
/** A draft becomes published: listed (unless unlisted), and people can get it. */
async publish(byId: string, id: string) {
const p = await this.editable(id, { creator: true, editLocked: false, rateLower: false });
if (p.status !== "draft") throw new ProductError(409, "published", "It's published already.");
if (!nameRule(p.name, 64)) throw new ProductError(400, "invalid", "Give it a name first.");
return this.db.transaction(async (tx) => {
const [updated] = await tx
.update(product)
.set({ status: "published", publishedAt: new Date() })
.where(eq(product.id, id))
.returning();
await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: "publish", changes: { status: { from: "draft", to: "published" } } });
return updated!;
});
}
/** Moderators lock info fields (the route checked product:lock); the reason is kept in the history. */
async setLocks(byId: string, id: string, fields: string[], reason: string) {
const p = await this.editable(id, { creator: false, editLocked: true, rateLower: true });
const want = infoFields.filter((f) => fields.includes(f));
if (want.length !== new Set(fields).size)
throw new ProductError(400, "invalid", `Only these can be locked: ${infoFields.join(", ")}.`);
reason = reason.trim().slice(0, 500);
if (!reason) throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason.");
if (JSON.stringify(want) === JSON.stringify(infoFields.filter((f) => p.locked.includes(f)))) return p;
return this.db.transaction(async (tx) => {
const [updated] = await tx.update(product).set({ locked: want }).where(eq(product.id, id)).returning();
await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: "lock", changes: { locked: { from: p.locked, to: want } }, reason });
return updated!;
});
}
/** A product's info history, newest first, with who made each change. */
async history(id: string) {
return this.db
.select({
id: productInfoChange.id,
at: productInfoChange.createdAt,
kind: productInfoChange.kind,
changes: productInfoChange.changes,
reason: productInfoChange.reason,
revertOf: productInfoChange.revertOf,
byId: productInfoChange.byId,
by: user.displayUsername,
})
.from(productInfoChange)
.leftJoin(user, eq(user.id, productInfoChange.byId))
.where(eq(productInfoChange.productId, id))
.orderBy(desc(productInfoChange.id));
}
/** Puts back what a history entry changed, as a new entry (the same rules as an edit). */
async revert(byId: string, id: string, entryId: number, reason: string, rights: EditRights) {
const [e] = await this.db
.select()
.from(productInfoChange)
.where(and(eq(productInfoChange.id, entryId), eq(productInfoChange.productId, id)));
if (!e || (e.kind !== "edit" && e.kind !== "revert")) throw new ProductError(404, "not_found", "No such change to undo.");
const input: Record<string, unknown> = {};
for (const f of infoFields) if (e.changes[f]) input[f] = e.changes[f].from;
const done = await this.updateInfo(byId, id, input as InfoInput, reason, rights, e.id);
if (done.fields.length === 0) throw new ProductError(409, "unchanged", "Its info is already as it was before that change.");
return done;
}
/** Deletes drafts never published within draftDays; their catalogs are told to delete the files. */
async purgeDrafts(now = new Date()): Promise<number> {
const before = new Date(now.getTime() - draftDays * 24 * 60 * 60 * 1000);
const stale = await this.db
.select({ id: product.id })
.from(product)
.where(and(eq(product.status, "draft"), sql`${product.createdAt} < ${before.toISOString()}::timestamptz`));
for (const { id } of stale) {
await this.db.transaction(async (tx) => {
const hosted = await tx
.select({ catalogId: hosting.catalogId, number: hosting.number, files: productRevision.files })
.from(hosting)
.innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)))
.where(eq(hosting.productId, id));
for (const h of hosted)
await tx
.insert(catalogChange)
.values({ catalogId: h.catalogId, data: { type: "delete", product: id, revision: h.number, files: h.files } });
await tx.delete(hosting).where(eq(hosting.productId, id));
await tx.delete(product).where(eq(product.id, id));
});
}
return stale.length;
}
/**
* Products for the console: a person's own, or with `all` (moderators) everyone's; drafts, unlisted, deleted and
* removed ones included. `status`: draft, published, unlisted, deleted, removed or locked.
*/
async manage(opts: { creatorId?: string; q?: string; status?: string; limit?: number }) {
const conds = [ne(product.latest, 0)];
if (opts.creatorId) conds.push(eq(product.creatorId, opts.creatorId));
if (opts.q) {
const like = `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
conds.push(or(ilike(product.name, like), eq(product.id, opts.q), ilike(user.username, like))!);
}
const live = and(isNull(product.deletedAt), isNull(product.removedAt))!;
switch (opts.status) {
case "draft":
conds.push(eq(product.status, "draft"), live);
break;
case "published":
conds.push(eq(product.status, "published"), eq(product.listed, true), live);
break;
case "unlisted":
conds.push(eq(product.status, "published"), eq(product.listed, false), live);
break;
case "deleted":
conds.push(sql`${product.deletedAt} is not null`, isNull(product.removedAt));
break;
case "removed":
conds.push(sql`${product.removedAt} is not null`);
break;
case "locked":
conds.push(sql`jsonb_array_length(${product.locked}) > 0`);
break;
}
return this.db
.select({
product,
creator: user.displayUsername,
creatorUsername: user.username,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
libraries: sql<number>`(select count(*) from ${library} l where l.product_id = ${product.id})::int`,
})
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(...conds))
.orderBy(desc(product.updatedAt))
.limit(Math.max(1, Math.min(opts.limit ?? 100, 200)));
}
/** How many drafts a person has (for the console's to-dos). */
async draftCount(creatorId: string) {
const [row] = await this.db
.select({ n: sql<number>`count(*)::int` })
.from(product)
.where(and(eq(product.creatorId, creatorId), eq(product.status, "draft"), isNull(product.deletedAt), ne(product.latest, 0)));
return row?.n ?? 0;
}
async retract(personId: string, id: string, number: number) {
@@ -474,8 +737,9 @@ export class Products {
return row;
}
async add(personId: string, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
async add(personId: string, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
if (p.product.status !== "published") throw new ProductError(409, "draft", "This product isn't published yet.");
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product.");
if (!(await this.available(personId, productId)))
@@ -524,8 +788,8 @@ export class Products {
* The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to
* the nearest earlier one that isn't (or later, if none).
*/
async revisionFor(personId: string | undefined, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
async revisionFor(personId: string | undefined, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
const want = pin ?? p.product.latest;
const usable = p.revisions.filter((r) => !r.retracted);
+1
View File
@@ -107,6 +107,7 @@ async function upload(maker: Person, c: { id: string; auth: Record<string, strin
c.auth,
);
expect(r.status).toBe(201);
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
return r.json.product as string;
}
+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);
const made = await revision(up.json.token);
expect(made.status).toBe(201);
expect(made.json.revision).toBe(1);
expect(made.json).toMatchObject({ revision: 1, draft: true });
const id = made.json.product as string;
expect(id).toMatch(/^[a-z2-7]{10}$/);
// A new product is a draft: only its creator (and moderators) see it, and nobody can get it until it's published.
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).status).toBe(404);
expect((await call(kid, "GET", `/api/v1/products/${id}`)).status).toBe(404);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).status).toBe(404);
expect((await call(admin, "GET", `/api/v1/products/${id}`)).json.product.status).toBe("draft");
expect((await call(maker, "GET", `/api/v1/products/${id}`)).json.product).toMatchObject({ status: "draft", publishedAt: null });
expect((await call(maker, "PUT", `/api/v1/library/${id}`)).json.error).toBe("draft");
expect((await call(maker, "GET", "/api/v1/products?creator=maker")).json.products).toEqual([]);
expect((await call(kid, "POST", `/api/v1/products/${id}/publish`)).status).toBe(403);
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.product.status).toBe("published");
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.error).toBe("published");
// Its page: info, revisions (files sorted), hosting.
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" });
@@ -147,7 +159,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect((await revision(up2)).status).toBe(400);
expect((await revision(up2, { notes: "same" })).status).toBe(409);
const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] });
expect(r2.json).toEqual({ product: id, revision: 2 });
expect(r2.json).toEqual({ product: id, revision: 2, draft: false });
// Someone else's upload token can't add to it.
expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403);
@@ -174,6 +186,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
// Adult products are only for adults.
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product;
await call(maker, "POST", `/api/v1/products/${adultID}/publish`);
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
@@ -230,6 +243,7 @@ test("derived products carry their IMVU parent's image and categories", async ()
const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth);
expect(r.status).toBe(201);
await call(dresser, "POST", `/api/v1/products/${r.json.product}/publish`);
return r.json.product as string;
};
const coatID = await upload("Dresser Coat", 7001);
@@ -287,6 +301,7 @@ test("creators delete products (those who have them keep them); moderation remov
const make = async (name: string) => {
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`);
return r.json.product as string;
};
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
@@ -313,3 +328,106 @@ test("creators delete products (those who have them keep them); moderation remov
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
});
test("product info: moderators edit with reasons and lock fields; history and reverts; unlisted and tags", async () => {
const { admin, catalogID, auth } = shared;
const maker = await person("tailor", { id: 521, ageVerified: true });
const other = await person("browser", { id: 522, ageVerified: true });
const mod = await person("warden", { id: 523, ageVerified: true });
const modID = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${modID}/role`, { role: "moderator" })).status).toBe(200);
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const id = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("vest.xml", 7)], rating: "general", parent: 0, name: "Vest", notes: "" }, auth)).json.product as string;
// The creator sets it up while it's a draft: tags (cleaned), unlisted; then publishes.
const set = await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: [" Formal ", "formal", "Wool Blend"], listed: false });
expect(set.json.fields).toEqual(["tags", "listed"]);
expect(set.json.product).toMatchObject({ tags: ["formal", "wool blend"], listed: false });
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: ["<b>"] })).json.error).toBe("invalid");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: false })).json.fields).toEqual([]);
await call(maker, "POST", `/api/v1/products/${id}/publish`);
// Unlisted: not in lists or search for others (its creator still sees it); its link works, and people can get it.
const names = async (p: Person | undefined, qs: string) => (await call(p, "GET", `/api/v1/products?${qs}`)).json.products.map((x: any) => x.name);
expect(await names(other, "creator=tailor")).toEqual([]);
expect(await names(maker, "creator=tailor")).toEqual(["Vest"]);
expect((await call(other, "GET", `/api/v1/products/${id}`)).status).toBe(200);
expect((await call(other, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: true });
expect(await names(other, "tag=formal")).toEqual(["Vest"]);
expect(await names(other, "tag=Wool%20Blend")).toEqual(["Vest"]);
expect(await names(other, "tag=denim")).toEqual([]);
expect(await names(other, `creator=tailor&catalog=${catalogID}`)).toEqual(["Vest"]);
// Others can't edit; a moderator can, with a reason the creator sees.
expect((await call(other, "PATCH", `/api/v1/products/${id}`, { name: "Mine" })).json).toMatchObject({
error: "forbidden",
missing: { permission: "product:edit" },
});
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest" })).json.error).toBe("reason_needed");
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest", reason: "Misleading name" })).status).toBe(200);
// Locks: the creator can't change a locked field; moderators can. The page lists what the person may do.
expect((await call(maker, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "x" })).status).toBe(403);
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "" })).json.error).toBe("reason_needed");
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "Keeps renaming it" })).json.product.locked).toEqual(["name"]);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Vest!" })).json.error).toBe("locked");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { description: "<p>Warm</p>" })).status).toBe(200);
const page = (await call(maker, "GET", `/api/v1/products/${id}`)).json;
expect(page.product.locked).toEqual(["name"]);
expect(page.permissions).toContain("product:edit");
expect(page.permissions).not.toContain("product:edit-locked");
expect((await call(mod, "GET", `/api/v1/products/${id}`)).json.permissions).toContain("product:edit-locked");
// Ratings: anyone with product:edit raises it; lowering is for moderators, and not below its files' rating.
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "adult" })).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "general" })).json.error).toBe("review_needed");
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { rating: "general", reason: "Not adult" })).status).toBe(200);
// The history: every change, newest first, with who and why. Only the creator and moderators read it.
expect((await call(other, "GET", `/api/v1/products/${id}/history`)).status).toBe(403);
const history = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries;
expect(history.map((e: any) => [e.kind, Object.keys(e.changes).join(), e.by, e.byCreator])).toEqual([
["edit", "rating", "warden", false],
["edit", "rating", "tailor", true],
["edit", "description", "tailor", true],
["lock", "locked", "warden", false],
["edit", "name", "warden", false],
["edit", "listed", "tailor", true],
["publish", "status", "tailor", true],
["edit", "tags,listed", "tailor", true],
]);
expect(history[4]).toMatchObject({ reason: "Misleading name", changes: { name: { from: "Vest", to: "Wool Vest" } } });
// Reverting puts back what an entry changed, by the same rules: the name is locked for the creator.
const rename = history[4].id;
expect((await call(maker, "POST", `/api/v1/products/${id}/history/${rename}/revert`, {})).json.error).toBe("locked");
const reverted = await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "My mistake" });
expect(reverted.json.product.name).toBe("Vest");
expect((await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "Again" })).json.error).toBe("unchanged");
const latest = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries[0];
expect(latest).toMatchObject({ kind: "revert", revertOf: rename, reason: "My mistake" });
// Moderators' edits are in the creator's audit log, tagged with the product.
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${id}`)).json.entries;
expect(audit.map((e: any) => e.action)).toContain("product.lock");
// The console's product list: the creator's own (drafts too); moderators everyone's, filtered.
const draftToken = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const draft = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: draftToken, files: [file("cap.xml", 8)], rating: "general", parent: 0, name: "Cap", notes: "" }, auth)).json.product as string;
const mine = (await call(maker, "GET", "/api/v1/console/products")).json;
expect(mine.products.map((p: any) => [p.name, p.status, p.libraries])).toEqual([["Cap", "draft", 0], ["Vest", "published", 1]]);
expect((await call(maker, "GET", "/api/v1/console")).json.todo.drafts).toBe(1);
expect((await call(other, "GET", "/api/v1/console/products")).status).toBe(403);
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=locked")).json.products.map((p: any) => p.id)).toEqual([id]);
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=draft&q=tailor")).json.products.map((p: any) => p.id)).toEqual([draft]);
// Drafts never published go after 30 days, and their catalogs are told to delete the files.
expect(await hub.products.purgeDrafts(new Date(Date.now() + 29 * 24 * 60 * 60 * 1000))).toBe(0);
expect(await hub.products.purgeDrafts(new Date(Date.now() + 31 * 24 * 60 * 60 * 1000))).toBeGreaterThanOrEqual(1);
expect((await call(maker, "GET", `/api/v1/products/${draft}`)).status).toBe(404);
expect((await call(maker, "GET", `/api/v1/products/${id}`)).status).toBe(200);
const changes = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, auth)).json.changes;
expect(changes.find((c: any) => c.product === draft)).toMatchObject({ type: "delete", revision: 1, files: [file("cap.xml", 8)] });
});
+2 -1
View File
@@ -32,9 +32,10 @@ export async function requireConsole(path: string): Promise<{ me: Person; consol
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 }[] = [
{ section: "overview", href: "/console", label: "Overview" },
{ section: "products", href: "/console/products", label: "Products" },
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
{ section: "people", href: "/console/people", label: "People" },
{ 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 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" },
];
if (data.moderation) {
@@ -28,7 +29,7 @@ export default async function Console() {
);
}
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 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>
</>
);
}
+225 -14
View File
@@ -2,44 +2,95 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StateTags } from "@/components/products";
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 { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { UploadCard } from "@/components/upload-card";
import { getMe } from "@/lib/api";
import { saveProduct } from "../../actions";
import { api, getMe, unwrap, type InfoEntry } from "@/lib/api";
import { publishProduct, revertInfo, saveLocks, saveProduct } from "../../actions";
import { loadProduct } from "../../load";
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 }> }) {
const { id } = await params;
const me = await getMe();
if (!me) redirect(`/signin?next=/products/${id}/edit`);
const { product, mine } = await loadProduct(id);
if (!mine) notFound();
const { product, mine, permissions } = await loadProduct(id);
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 (
<div className="space-y-6">
<PageHeader title={`Edit ${product.name}`}>
<PageHeader title={draft ? `Review ${product.name}` : `Edit ${product.name}`}>
<span className="flex flex-wrap items-center gap-2">
<StateTags product={product} />
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
Back to the product
{draft ? "Preview its page" : "Back to the product"}
</Link>
</span>
</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>
<CardHeader>
<CardTitle>Info</CardTitle>
{!mine && <CardDescription>You&apos;re editing someone else&apos;s product: they see your reason.</CardDescription>}
</CardHeader>
<CardContent>
<ActionForm action={saveProduct} fields={{ id }}>
<FieldGroup>
<Field>
<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>
<FieldLabel htmlFor="description">Description</FieldLabel>
@@ -49,22 +100,69 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
defaultValue={product.description}
maxLength={20000}
rows={8}
disabled={locked("description")}
className="font-mono text-xs md:text-xs"
/>
{locked("description") ? (
<LockedNote />
) : (
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
)}
</Field>
<Field>
<FieldLabel htmlFor="tags">Tags</FieldLabel>
<Input
id="tags"
name="tags"
defaultValue={product.tags.join(", ")}
maxLength={300}
disabled={locked("tags")}
placeholder="formal, wool, winter"
className="max-w-md"
/>
{locked("tags") ? <LockedNote /> : <FieldDescription>Up to 10, separated by commas.</FieldDescription>}
</Field>
<div className="grid gap-6 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
{product.rating === "adult" ? (
<p className="text-sm">Adult</p>
{locked("rating") || (product.rating === "adult" && !can("product:rate-lower")) ? (
<p className="text-sm capitalize">{product.rating}</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue="general" className="w-48">
<NativeSelect id="rating" name="rating" defaultValue={product.rating} className="w-48">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult">Adult</NativeSelectOption>
</NativeSelect>
)}
<FieldDescription>Raising it to adult is immediate; lowering it needs review.</FieldDescription>
{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>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
@@ -73,7 +171,120 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
</CardContent>
</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>
);
}
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 Link from "next/link";
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 { DateTime } from "@/components/time";
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 }> }) {
const { id } = await params;
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 draft = product.status === "draft";
return (
<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">
{product.creator && <span>by {product.creator}</span>}
<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>
</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 && (
<p className="text-sm text-muted-foreground">
{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>
)}
{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">
{product.deleted && !inLibrary ? null : me ? (
{draft || (product.deleted && !inLibrary) ? null : me ? (
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{inLibrary ? (
@@ -88,11 +121,13 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
Sign in to get it
</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 && (
<>
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
Edit
</Link>
<ActionForm action={deleteProduct} fields={{ id }}>
<SubmitButton
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> {
const id = field(form, "id");
const has = (name: string) => form.has(name);
const rating = field(form, "rating");
const state = await attempt(async () => {
unwrap(
const listed = field(form, "listed");
return attempt(async () => {
const { fields } = unwrap(
await api.products({ id }).patch({
name: field(form, "name"),
description: typeof form.get("description") === "string" ? String(form.get("description")) : "",
...(rating === "adult" ? { rating: "adult" as const } : {}),
...(has("name") ? { name: field(form, "name") } : {}),
...(has("description") ? { description: String(form.get("description") ?? "") } : {}),
...(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);
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;
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. */
export async function startUpload(
catalog: string,
+1 -1
View File
@@ -13,7 +13,7 @@ export default async function NewProduct() {
return (
<div className="space-y-6">
<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">
Back to products
</Link>
+10 -4
View File
@@ -11,21 +11,26 @@ export const metadata: Metadata = { title: "Products" };
export default async function Products({
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 one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
const q = one(params.q, 100);
const creator = one(params.creator, 64);
const tag = one(params.tag, 24);
const [me, { products }] = await Promise.all([
getMe(),
api.products.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}) } }).then(unwrap),
api.products
.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}), ...(tag ? { tag } : {}) } })
.then(unwrap),
]);
return (
<>
<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 && (
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
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">
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
{creator && <input type="hidden" name="creator" value={creator} />}
{tag && <input type="hidden" name="tag" value={tag} />}
<Button type="submit" variant="outline">
Search
</Button>
</form>
{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">
{products.map((p) => (
+21 -1
View File
@@ -8,6 +8,23 @@ export function RatingTag({ rating }: { rating: string }) {
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". */
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -39,7 +56,7 @@ export function ProductCard({
product,
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;
}) {
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">
{product.name}
</Link>
<span className="flex gap-1">
{!product.listed && <Tag>Unlisted</Tag>}
<RatingTag rating={product.rating} />
</span>
</div>
<p className="text-xs text-muted-foreground">
{[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 value = answer.body as { product?: unknown; message?: unknown } | null;
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();
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 ProductPage = Data<ReturnType<V1["products"]>["get"]>;
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 LibraryEntry = Data<V1["library"]["get"]>["library"][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
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
latest revision (its IMVU parent's public info). `GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU
category ID) and `limit` (1-200, default 60).
creatorUsername, parent, image, categories, status, listed, tags, locked, deleted, removed, publishedAt, createdAt,
updatedAt}`; `parent`, `image` and `categories` come from the latest revision (its IMVU parent's public info).
`GET /api/v1/products` takes `q`, `creator`, `category` (an IMVU category ID), `tag`, `catalog` and `limit` (1-200,
default 60).
## 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/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/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:
+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
(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.
**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;
two-factor sign-in for sensitive permissions.
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. |
| `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) |
| `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}` |
| `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. |
| `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 |
| `GET /api/v1/products/:id/history` | the info history: `{entries: [{at, by, fields, reason}]}`; `POST .../history/:n/revert` |
| `PUT /api/v1/products/:id/locks` | (`product:lock`) `{fields, reason}` |
| `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 (409 `published` if it is already) |
| `GET /api/v1/products/:id/history` | (`audit:read` at the product: its creator, moderators) the info history, newest first: `{entries: [{id, at, kind ("edit", "revert", "lock", "publish"), by, byCreator, changes: {field: {from, to}}, reason, revertOf}]}` |
| `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/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) |
@@ -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.
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,
updatedAt}`. `parent`, `image` and `categories` are the latest revision's:
parent, image, categories, status ("draft"/"published"), listed, tags, locked, deleted, removed, publishedAt,
imvuProduct, createdAt, updatedAt}` (`imvuProduct` comes with imports). The product's page also has `permissions`: what the
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
`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>`).
+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,
"tag": "0010_catalog_management",
"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";
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";
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
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
// "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.
deletedAt: timestamp("deleted_at", { withTimezone: true }),
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
@@ -367,7 +375,32 @@ export const product = pgTable(
createdAt: createdAt(),
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).