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
+4106 -96

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})`);
+291 -27
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();
return updated!;
if (input.tags !== undefined) {
const tags = checkTags(input.tags);
if (!tags)
throw new ProductError(400, "invalid", "Up to 10 tags, each up to 24 letters, digits, spaces or dashes.");
next.tags = tags;
}
if (input.listed !== undefined) next.listed = input.listed;
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const changes: Record<string, { from: unknown; to: unknown }> = {};
for (const f of infoFields)
if (next[f] !== undefined && !same(next[f], p[f])) changes[f] = { from: p[f], to: next[f] };
const fields = Object.keys(changes) as InfoField[];
if (fields.length === 0) return { product: p, fields };
const locked = fields.filter((f) => p.locked.includes(f));
if (locked.length && !rights.editLocked)
throw new ProductError(403, "locked", `A moderator locked this product's ${locked.join(", ")}.`);
if (changes.rating?.to === "general") {
if (!rights.rateLower)
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
const [adultFiles] = await this.db
.select({ n: productRevision.number })
.from(productRevision)
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"), eq(productRevision.retracted, false)))
.limit(1);
if (adultFiles)
throw new ProductError(400, "files_adult", `Revision ${adultFiles.n}'s files are rated adult; retract it first.`);
}
return this.db.transaction(async (tx) => {
const [updated] = await tx.update(product).set(next).where(eq(product.id, id)).returning();
const [entry] = await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: revertOf ? "revert" : "edit", changes, reason, revertOf })
.returning({ id: productInfoChange.id });
return { product: updated!, entry: entry!.id, fields };
});
}
/** A draft becomes published: listed (unless unlisted), and people can get it. */
async publish(byId: string, id: string) {
const p = await this.editable(id, { creator: true, editLocked: false, rateLower: false });
if (p.status !== "draft") throw new ProductError(409, "published", "It's published already.");
if (!nameRule(p.name, 64)) throw new ProductError(400, "invalid", "Give it a name first.");
return this.db.transaction(async (tx) => {
const [updated] = await tx
.update(product)
.set({ status: "published", publishedAt: new Date() })
.where(eq(product.id, id))
.returning();
await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: "publish", changes: { status: { from: "draft", to: "published" } } });
return updated!;
});
}
/** Moderators lock info fields (the route checked product:lock); the reason is kept in the history. */
async setLocks(byId: string, id: string, fields: string[], reason: string) {
const p = await this.editable(id, { creator: false, editLocked: true, rateLower: true });
const want = infoFields.filter((f) => fields.includes(f));
if (want.length !== new Set(fields).size)
throw new ProductError(400, "invalid", `Only these can be locked: ${infoFields.join(", ")}.`);
reason = reason.trim().slice(0, 500);
if (!reason) throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason.");
if (JSON.stringify(want) === JSON.stringify(infoFields.filter((f) => p.locked.includes(f)))) return p;
return this.db.transaction(async (tx) => {
const [updated] = await tx.update(product).set({ locked: want }).where(eq(product.id, id)).returning();
await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: "lock", changes: { locked: { from: p.locked, to: want } }, reason });
return updated!;
});
}
/** A product's info history, newest first, with who made each change. */
async history(id: string) {
return this.db
.select({
id: productInfoChange.id,
at: productInfoChange.createdAt,
kind: productInfoChange.kind,
changes: productInfoChange.changes,
reason: productInfoChange.reason,
revertOf: productInfoChange.revertOf,
byId: productInfoChange.byId,
by: user.displayUsername,
})
.from(productInfoChange)
.leftJoin(user, eq(user.id, productInfoChange.byId))
.where(eq(productInfoChange.productId, id))
.orderBy(desc(productInfoChange.id));
}
/** Puts back what a history entry changed, as a new entry (the same rules as an edit). */
async revert(byId: string, id: string, entryId: number, reason: string, rights: EditRights) {
const [e] = await this.db
.select()
.from(productInfoChange)
.where(and(eq(productInfoChange.id, entryId), eq(productInfoChange.productId, id)));
if (!e || (e.kind !== "edit" && e.kind !== "revert")) throw new ProductError(404, "not_found", "No such change to undo.");
const input: Record<string, unknown> = {};
for (const f of infoFields) if (e.changes[f]) input[f] = e.changes[f].from;
const done = await this.updateInfo(byId, id, input as InfoInput, reason, rights, e.id);
if (done.fields.length === 0) throw new ProductError(409, "unchanged", "Its info is already as it was before that change.");
return done;
}
/** Deletes drafts never published within draftDays; their catalogs are told to delete the files. */
async purgeDrafts(now = new Date()): Promise<number> {
const before = new Date(now.getTime() - draftDays * 24 * 60 * 60 * 1000);
const stale = await this.db
.select({ id: product.id })
.from(product)
.where(and(eq(product.status, "draft"), sql`${product.createdAt} < ${before.toISOString()}::timestamptz`));
for (const { id } of stale) {
await this.db.transaction(async (tx) => {
const hosted = await tx
.select({ catalogId: hosting.catalogId, number: hosting.number, files: productRevision.files })
.from(hosting)
.innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)))
.where(eq(hosting.productId, id));
for (const h of hosted)
await tx
.insert(catalogChange)
.values({ catalogId: h.catalogId, data: { type: "delete", product: id, revision: h.number, files: h.files } });
await tx.delete(hosting).where(eq(hosting.productId, id));
await tx.delete(product).where(eq(product.id, id));
});
}
return stale.length;
}
/**
* Products for the console: a person's own, or with `all` (moderators) everyone's; drafts, unlisted, deleted and
* removed ones included. `status`: draft, published, unlisted, deleted, removed or locked.
*/
async manage(opts: { creatorId?: string; q?: string; status?: string; limit?: number }) {
const conds = [ne(product.latest, 0)];
if (opts.creatorId) conds.push(eq(product.creatorId, opts.creatorId));
if (opts.q) {
const like = `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
conds.push(or(ilike(product.name, like), eq(product.id, opts.q), ilike(user.username, like))!);
}
const live = and(isNull(product.deletedAt), isNull(product.removedAt))!;
switch (opts.status) {
case "draft":
conds.push(eq(product.status, "draft"), live);
break;
case "published":
conds.push(eq(product.status, "published"), eq(product.listed, true), live);
break;
case "unlisted":
conds.push(eq(product.status, "published"), eq(product.listed, false), live);
break;
case "deleted":
conds.push(sql`${product.deletedAt} is not null`, isNull(product.removedAt));
break;
case "removed":
conds.push(sql`${product.removedAt} is not null`);
break;
case "locked":
conds.push(sql`jsonb_array_length(${product.locked}) > 0`);
break;
}
return this.db
.select({
product,
creator: user.displayUsername,
creatorUsername: user.username,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
libraries: sql<number>`(select count(*) from ${library} l where l.product_id = ${product.id})::int`,
})
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(...conds))
.orderBy(desc(product.updatedAt))
.limit(Math.max(1, Math.min(opts.limit ?? 100, 200)));
}
/** How many drafts a person has (for the console's to-dos). */
async draftCount(creatorId: string) {
const [row] = await this.db
.select({ n: sql<number>`count(*)::int` })
.from(product)
.where(and(eq(product.creatorId, creatorId), eq(product.status, "draft"), isNull(product.deletedAt), ne(product.latest, 0)));
return row?.n ?? 0;
}
async retract(personId: string, id: string, number: number) {
@@ -474,8 +737,9 @@ export class Products {
return row;
}
async add(personId: string, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
async add(personId: string, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
if (p.product.status !== "published") throw new ProductError(409, "draft", "This product isn't published yet.");
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product.");
if (!(await this.available(personId, productId)))
@@ -524,8 +788,8 @@ export class Products {
* The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to
* the nearest earlier one that isn't (or later, if none).
*/
async revisionFor(personId: string | undefined, productId: string, adult: boolean) {
const p = await this.get(productId, adult);
async revisionFor(personId: string | undefined, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
const want = pin ?? p.product.latest;
const usable = p.revisions.filter((r) => !r.retracted);
+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>
</>
);
}
+234 -23
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}`}>
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
Back to the product
</Link>
<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">
{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"
/>
<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>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
{product.rating === "adult" ? (
<p className="text-sm">Adult</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue="general" 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>
<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>
{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>
<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) => (
+22 -2
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>
<RatingTag rating={product.rating} />
<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];