From 8793b5afd81c47ccf954524453493770830a909e Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 5 Nov 2026 05:40:43 -0800 Subject: [PATCH 1/2] feat: product drafts, moderator edits with reasons and locks, info history, unlisted products and tags New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and publishes it. Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings (product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why, and can be undone by the same rules. Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do. A product's own category is left for later: the hub has no list of IMVU's categories to pick from. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 175 +- apps/api/src/index.ts | 10 +- apps/api/src/products.ts | 318 +- apps/api/test/catalogs.test.ts | 1 + apps/api/test/products.test.ts | 122 +- apps/web/app/console/console-nav.tsx | 3 +- apps/web/app/console/page.tsx | 3 +- apps/web/app/console/products/page.tsx | 130 + apps/web/app/products/[id]/edit/page.tsx | 257 +- apps/web/app/products/[id]/page.tsx | 49 +- apps/web/app/products/actions.ts | 66 +- apps/web/app/products/new/page.tsx | 2 +- apps/web/app/products/page.tsx | 14 +- apps/web/components/products.tsx | 24 +- apps/web/components/upload-form.tsx | 3 +- apps/web/lib/api.ts | 2 + docs/api.md | 10 +- docs/design.md | 2 + docs/products.md | 16 +- packages/db/drizzle/0011_product_info.sql | 22 + packages/db/drizzle/meta/0011_snapshot.json | 2929 +++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/index.ts | 2 +- packages/db/src/schema.ts | 35 +- 24 files changed, 4106 insertions(+), 96 deletions(-) create mode 100644 apps/web/app/console/products/page.tsx create mode 100644 packages/db/drizzle/0011_product_info.sql create mode 100644 packages/db/drizzle/meta/0011_snapshot.json diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 31297dc..a17fce5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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 => ({ + 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, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1276ee1..c8a31be 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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})`); diff --git a/apps/api/src/products.ts b/apps/api/src/products.ts index e085ff1..26e034a 100644 --- a/apps/api/src/products.ts +++ b/apps/api/src/products.ts @@ -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 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> = {}; 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 = {}; + 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 = {}; + 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 { + 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`(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`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); diff --git a/apps/api/test/catalogs.test.ts b/apps/api/test/catalogs.test.ts index a35eb80..9899de4 100644 --- a/apps/api/test/catalogs.test.ts +++ b/apps/api/test/catalogs.test.ts @@ -107,6 +107,7 @@ async function upload(maker: Person, c: { id: string; auth: Record { 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: [""] })).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: "

Warm

" })).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)] }); +}); diff --git a/apps/web/app/console/console-nav.tsx b/apps/web/app/console/console-nav.tsx index 900c44f..f8d1af0 100644 --- a/apps/web/app/console/console-nav.tsx +++ b/apps/web/app/console/console-nav.tsx @@ -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" }, diff --git a/apps/web/app/console/page.tsx b/apps/web/app/console/page.tsx index 322b329..84e3e5f 100644 --- a/apps/web/app/console/page.tsx +++ b/apps/web/app/console/page.tsx @@ -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" }, ]; diff --git a/apps/web/app/console/products/page.tsx b/apps/web/app/console/products/page.tsx new file mode 100644 index 0000000..e550d35 --- /dev/null +++ b/apps/web/app/console/products/page.tsx @@ -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> }) { + 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 ( + <> +
+ + {data.all ? "Everyone's products, drafts and removed ones included." : "Your products, drafts included."} + + + New product + +
+ +
+ + + {statuses.map(([value, label]) => ( + + {label} + + ))} + + {data.moderator && ( + + Yours + Everyone's + + )} + + {(filtered || all) && ( + + Clear + + )} +
+ + + {data.products.length === 0 ? ( +

{filtered ? "Nothing matches." : "None yet."}

+ ) : ( + + + + Name + {data.all && Creator} + State + Libraries + Changed + + + + + {data.products.map((p) => ( + + + + {p.name} + + + {data.all && {p.creator}} + + + + + + + {p.libraries} + + + + + {!p.removed && ( + + {p.status === "draft" ? "Review" : "Edit"} + + )} + + + ))} + +
+ )} +
+
+ + ); +} diff --git a/apps/web/app/products/[id]/edit/page.tsx b/apps/web/app/products/[id]/edit/page.tsx index 4ce54c8..0b127a6 100644 --- a/apps/web/app/products/[id]/edit/page.tsx +++ b/apps/web/app/products/[id]/edit/page.tsx @@ -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 = { + 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 (
- - - Back to the product - + + + + + {draft ? "Preview its page" : "Back to the product"} + + + {draft && ( + + + Draft + + 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. + + + {can("product:publish") && ( + + + Publish + + + )} + + )} + Info + {!mine && You're editing someone else's product: they see your reason.} Name - + + {locked("name") && } Description @@ -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" /> - HTML, as on IMVU. Scripts and unsafe tags are removed. + {locked("description") ? ( + + ) : ( + HTML, as on IMVU. Scripts and unsafe tags are removed. + )} - Rating - {product.rating === "adult" ? ( -

Adult

- ) : ( - - General - Adult - - )} - Raising it to adult is immediate; lowering it needs review. + Tags + + {locked("tags") ? : Up to 10, separated by commas.}
+
+ + Rating + {locked("rating") || (product.rating === "adult" && !can("product:rate-lower")) ? ( +

{product.rating}

+ ) : ( + + General + Adult + + )} + {locked("rating") ? ( + + ) : ( + Raising it to adult is immediate; lowering it needs a moderator. + )} +
+ + Listing + {locked("listed") ? ( +

{product.listed ? "Listed" : "Unlisted"}

+ ) : ( + + Listed + Unlisted + + )} + {locked("listed") ? ( + + ) : ( + Unlisted: left out of search and lists; its link still works. + )} +
+
+ {!mine && ( + + Reason + + Shown to the creator in the product's history. + + )}
Save
@@ -73,7 +171,120 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
- + {can("product:lock") && ( + + + Locks + Locked fields can only be changed by moderators. + + + +
+ {(["name", "description", "rating", "tags", "listed"] as const).map((f) => ( + + ))} +
+
+ + + Save locks + +
+
+
+
+ )} + + + + History + Every change to its info, with who made it and why. + + + {entries.length === 0 ? ( +

No changes yet.

+ ) : ( +
    + {entries.map((e) => ( + + ))} +
+ )} +
+
+ + {mine && }
); } + +function LockedNote() { + return Locked by a moderator.; +} + +/** 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 ( +
  • +
    + {e.by || "Someone"} + {!e.byCreator && Moderator} + {what} + + + +
    + {e.reason &&

    Reason: {e.reason}

    } + {e.kind !== "publish" && ( +
      + {Object.entries(e.changes).map(([field, c]) => ( +
    • + {fieldNames[field] ?? field} + {field === "description" ? ( +
      + Before and after +
      +                    {shown(field, c.from)}
      +                  
      +
      +                    {shown(field, c.to)}
      +                  
      +
      + ) : ( + <> + : {shown(field, c.from)} → {shown(field, c.to)} + + )} +
    • + ))} +
    + )} + {canRevert && (e.kind === "edit" || e.kind === "revert") && ( + + {!mine && } + + Undo + + + )} +
  • + ); +} diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx index da2d259..5aa579e 100644 --- a/apps/web/app/products/[id]/page.tsx +++ b/apps/web/app/products/[id]/page.tsx @@ -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 (
    @@ -28,10 +29,30 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st {product.creator && by {product.creator}} - {product.deleted && deleted} + {/* Its state (draft, unlisted, locks) is for those who manage it; anyone may see that it's deleted. */} + {permissions.includes("product:edit") ? ( + + ) : ( + product.deleted && Deleted + )} + {draft && ( +

    + A draft: only {mine ? "you" : "its creator"} and moderators see it, and nobody can get it until it's + published.{" "} + {permissions.includes("product:publish") && ( + + Review and publish + + )} +

    + )} + {!draft && !product.listed && mine && ( +

    Unlisted: it's not in search or lists, but anyone with its link can get it.

    + )} + {product.deleted && (

    {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

    )} + {product.tags.length > 0 && ( +
      + {product.tags.map((t) => ( +
    • + + {t} + +
    • + ))} +
    + )} +
    - {product.deleted && !inLibrary ? null : me ? ( + {draft || (product.deleted && !inLibrary) ? null : me ? (
    {inLibrary ? ( @@ -88,11 +121,13 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st Sign in to get it )} + {permissions.includes("product:edit") && !(mine && product.deleted) && ( + + {mine ? "Edit" : "Edit info"} + + )} {mine && !product.deleted && ( <> - - Edit - { 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 { + 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 { + 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 { + 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, diff --git a/apps/web/app/products/new/page.tsx b/apps/web/app/products/new/page.tsx index c235704..0fec624 100644 --- a/apps/web/app/products/new/page.tsx +++ b/apps/web/app/products/new/page.tsx @@ -13,7 +13,7 @@ export default async function NewProduct() { return (
    - Upload it to a catalog. You can edit its info after.{" "} + Upload it to a catalog. Then check its info and publish it.{" "} Back to products diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx index 27c0669..e3c1cc9 100644 --- a/apps/web/app/products/page.tsx +++ b/apps/web/app/products/page.tsx @@ -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 ( <>
    - {creator ? `By ${creator}.` : "Community products for T5DE."} + + {[creator && `By ${creator}.`, tag && `Tagged “${tag}”.`].filter(Boolean).join(" ") || "Community products for T5DE."} + {me && ( New product @@ -35,12 +40,13 @@ export default async function Products({
    {creator && } + {tag && }
    {products.length === 0 ? ( - {q ? "Nothing found." : "No products yet."} + {q || tag || creator ? "Nothing found." : "No products yet."} ) : (
      {products.map((p) => ( diff --git a/apps/web/components/products.tsx b/apps/web/components/products.tsx index c55236f..49b98ed 100644 --- a/apps/web/components/products.tsx +++ b/apps/web/components/products.tsx @@ -8,6 +8,23 @@ export function RatingTag({ rating }: { rating: string }) { return rating === "adult" ? Adult : 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; +}) { + return ( + <> + {p.status === "draft" && Draft} + {p.status === "published" && !p.listed && Unlisted} + {p.deleted && Deleted} + {p.removed && Removed} + {p.locked.length > 0 && Locked: {p.locked.join(", ")}} + + ); +} + /** 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; + product: Pick; children?: React.ReactNode; }) { const category = product.categories.at(-1)?.name; @@ -51,7 +68,10 @@ export function ProductCard({ {product.name} - + + {!product.listed && Unlisted} + +

    {[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")} diff --git a/apps/web/components/upload-form.tsx b/apps/web/components/upload-form.tsx index 50ab900..eccdf96 100644 --- a/apps/web/components/upload-form.tsx +++ b/apps/web/components/upload-form.tsx @@ -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; } diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 0d7f5bc..dfc8b02 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -49,6 +49,8 @@ export type CatalogTeam = Data["team"]["get"]>; export type ProductSummary = Data["products"][number]; export type ProductPage = Data["get"]>; export type Revision = ProductPage["revisions"][number]; +export type InfoEntry = Data["history"]["get"]>["entries"][number]; +export type ManagedProduct = Data["products"][number]; export type Catalog = Data["catalogs"][number]; export type LibraryEntry = Data["library"][number]; export type HostedRoom = Data["rooms"][number]; diff --git a/docs/api.md b/docs/api.md index f7ac39c..edba45e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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: diff --git a/docs/design.md b/docs/design.md index 0ae4ebc..6f4eab0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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). diff --git a/docs/products.md b/docs/products.md index 2e4f507..5452793 100644 --- a/docs/products.md +++ b/docs/products.md @@ -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-`). diff --git a/packages/db/drizzle/0011_product_info.sql b/packages/db/drizzle/0011_product_info.sql new file mode 100644 index 0000000..620b4cf --- /dev/null +++ b/packages/db/drizzle/0011_product_info.sql @@ -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; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0011_snapshot.json b/packages/db/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..1735fd1 --- /dev/null +++ b/packages/db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2929 @@ +{ + "id": "5663cafb-98ed-4a05-8163-f86b813ea0a3", + "prevId": "2a4756bc-d197-4321-84ef-a8238ed002d3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_tags": { + "name": "audit_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog": { + "name": "catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "max_rating": { + "name": "max_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'adult'" + }, + "uploads": { + "name": "uploads", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anyone'" + }, + "mirrors": { + "name": "mirrors", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approve'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_url": { + "name": "catalog_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_owner_id_user_id_fk": { + "name": "catalog_owner_id_user_id_fk", + "tableFrom": "catalog", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_change": { + "name": "catalog_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_change_catalog": { + "name": "catalog_change_catalog", + "columns": [ + { + "expression": "catalog_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_change_catalog_id_catalog_id_fk": { + "name": "catalog_change_catalog_id_catalog_id_fk", + "tableFrom": "catalog_change", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_invite": { + "name": "catalog_invite", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uses": { + "name": "uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_invite_catalog": { + "name": "catalog_invite_catalog", + "columns": [ + { + "expression": "catalog_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_invite_catalog_id_catalog_id_fk": { + "name": "catalog_invite_catalog_id_catalog_id_fk", + "tableFrom": "catalog_invite", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_invite_created_by_user_id_fk": { + "name": "catalog_invite_created_by_user_id_fk", + "tableFrom": "catalog_invite", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_member": { + "name": "catalog_member", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invite": { + "name": "invite", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_member_user": { + "name": "catalog_member_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_member_catalog_id_catalog_id_fk": { + "name": "catalog_member_catalog_id_catalog_id_fk", + "tableFrom": "catalog_member", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_member_user_id_user_id_fk": { + "name": "catalog_member_user_id_user_id_fk", + "tableFrom": "catalog_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_member_decided_by_user_id_fk": { + "name": "catalog_member_decided_by_user_id_fk", + "tableFrom": "catalog_member", + "tableTo": "user", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "catalog_member_catalog_id_user_id_pk": { + "name": "catalog_member_catalog_id_user_id_pk", + "columns": [ + "catalog_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room": { + "name": "hosted_room", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "room_pid": { + "name": "room_pid", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "scene": { + "name": "scene", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_hash": { + "name": "scene_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "moderators": { + "name": "moderators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_owner": { + "name": "hosted_room_owner", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hosted_room_created": { + "name": "hosted_room_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_owner_id_user_id_fk": { + "name": "hosted_room_owner_id_user_id_fk", + "tableFrom": "hosted_room", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_ban": { + "name": "hosted_room_ban", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "by_imvu_id": { + "name": "by_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "until": { + "name": "until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_ban_n_hosted_room_n_fk": { + "name": "hosted_room_ban_n_hosted_room_n_fk", + "tableFrom": "hosted_room_ban", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_ban_n_imvu_id_pk": { + "name": "hosted_room_ban_n_imvu_id_pk", + "columns": [ + "n", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_favorite": { + "name": "hosted_room_favorite", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_favorite_user_id_user_id_fk": { + "name": "hosted_room_favorite_user_id_user_id_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_favorite_n_hosted_room_n_fk": { + "name": "hosted_room_favorite_n_hosted_room_n_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_favorite_user_id_n_pk": { + "name": "hosted_room_favorite_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_image": { + "name": "hosted_room_image", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_image_n_hosted_room_n_fk": { + "name": "hosted_room_image_n_hosted_room_n_fk", + "tableFrom": "hosted_room_image", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_rating": { + "name": "hosted_room_rating", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_rating_room": { + "name": "hosted_room_rating_room", + "columns": [ + { + "expression": "n", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_rating_user_id_user_id_fk": { + "name": "hosted_room_rating_user_id_user_id_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_rating_n_hosted_room_n_fk": { + "name": "hosted_room_rating_n_hosted_room_n_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_rating_user_id_n_pk": { + "name": "hosted_room_rating_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_visit": { + "name": "hosted_room_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_visit_at": { + "name": "hosted_room_visit_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_visit_user_id_user_id_fk": { + "name": "hosted_room_visit_user_id_user_id_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_visit_n_hosted_room_n_fk": { + "name": "hosted_room_visit_n_hosted_room_n_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_visit_user_id_n_pk": { + "name": "hosted_room_visit_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosting": { + "name": "hosting", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosting_product": { + "name": "hosting_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosting_catalog_id_catalog_id_fk": { + "name": "hosting_catalog_id_catalog_id_fk", + "tableFrom": "hosting", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosting_catalog_id_product_id_number_pk": { + "name": "hosting_catalog_id_product_id_number_pk", + "columns": [ + "catalog_id", + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hub_setting": { + "name": "hub_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "list_rooms": { + "name": "list_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library": { + "name": "library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stay_on": { + "name": "stay_on", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_user_id_user_id_fk": { + "name": "library_user_id_user_id_fk", + "tableFrom": "library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_product_id_product_id_fk": { + "name": "library_product_id_product_id_fk", + "tableFrom": "library", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_user_id_product_id_pk": { + "name": "library_user_id_product_id_pk", + "columns": [ + "user_id", + "product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest": { + "name": "latest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "listed": { + "name": "listed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "locked": { + "name": "locked", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_reason": { + "name": "removed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_creator": { + "name": "product_creator", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_created": { + "name": "product_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_tags": { + "name": "product_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "product_creator_id_user_id_fk": { + "name": "product_creator_id_user_id_fk", + "tableFrom": "product", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_info_change": { + "name": "product_info_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "by_id": { + "name": "by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "revert_of": { + "name": "revert_of", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_info_change_product": { + "name": "product_info_change_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_info_change_product_id_product_id_fk": { + "name": "product_info_change_product_id_product_id_fk", + "tableFrom": "product_info_change", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_info_change_by_id_user_id_fk": { + "name": "product_info_change_by_id_user_id_fk", + "tableFrom": "product_info_change", + "tableTo": "user", + "columnsFrom": [ + "by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_revision": { + "name": "product_revision", + "schema": "", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_info": { + "name": "parent_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retracted": { + "name": "retracted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "product_revision_product_id_product_id_fk": { + "name": "product_revision_product_id_product_id_fk", + "tableFrom": "product_revision", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_revision_uploaded_by_user_id_fk": { + "name": "product_revision_uploaded_by_user_id_fk", + "tableFrom": "product_revision", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_revision_product_id_number_pk": { + "name": "product_revision_product_id_number_pk", + "columns": [ + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_assignment": { + "name": "role_assignment", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_assignment_scope": { + "name": "role_assignment_scope", + "columns": [ + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_assignment_user_id_user_id_fk": { + "name": "role_assignment_user_id_user_id_fk", + "tableFrom": "role_assignment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_assignment_granted_by_user_id_fk": { + "name": "role_assignment_granted_by_user_id_fk", + "tableFrom": "role_assignment", + "tableTo": "user", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_assignment_user_id_role_scope_type_scope_id_pk": { + "name": "role_assignment_user_id_role_scope_type_scope_id_pk", + "columns": [ + "user_id", + "role", + "scope_type", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room": { + "name": "room", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_rating": { + "name": "content_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_pass": { + "name": "access_pass", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "occupancy": { + "name": "occupancy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expired": { + "name": "expired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_owner": { + "name": "room_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room_decoration": { + "name": "room_decoration", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "contents": { + "name": "contents", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_decoration_owner": { + "name": "room_decoration_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signing_key": { + "name": "signing_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index cec4d21..ce798ea 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1793862017365, "tag": "0010_catalog_management", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1793884711638, + "tag": "0011_product_info", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 2cd849a..100cc28 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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)); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 2479562..87c4586 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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().notNull().default([]), + // Info fields a moderator locked: only people with product:edit-locked change them. + locked: jsonb("locked").$type().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>().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). -- 2.54.0 From ceceb69eaf200432fba01b5f800992a0fd613648 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 5 Nov 2026 06:00:04 -0800 Subject: [PATCH 2/2] feat: choose a product's category from the ones T5DE has seen A product can be filed under another IMVU category than its parent's: any category path the hub has seen on a revision's parent (and each step of it), within the parent's top-level category, so it stays the same kind of product (the proxy tells clothing, furniture and rooms apart by category). It's an info field like the others: lockable, in the history, undone by the same rules. Lists filter by the chosen path, and products show it. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 28 ++++++++++-- apps/api/src/products.ts | 50 +++++++++++++++++++-- apps/api/test/products.test.ts | 22 +++++++++ apps/web/app/products/[id]/edit/page.tsx | 43 +++++++++++++++++- apps/web/app/products/actions.ts | 9 ++-- docs/api.md | 5 ++- docs/design.md | 5 ++- docs/products.md | 12 ++--- packages/db/drizzle/0011_product_info.sql | 1 + packages/db/drizzle/meta/0011_snapshot.json | 8 +++- packages/db/drizzle/meta/_journal.json | 2 +- packages/db/src/schema.ts | 2 + 12 files changed, 165 insertions(+), 22 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a17fce5..6c00307 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -332,7 +332,9 @@ export function createApp({ creatorUsername: creatorUsername ?? "", parent: latest?.parent ?? 0, image: latest?.parentInfo?.image ?? null, - categories: latest?.parentInfo?.categories ?? [], + // Its own category if one was chosen, else its parent's. + categories: p.category ?? latest?.parentInfo?.categories ?? [], + category: p.category, status: p.status as "draft" | "published", listed: p.listed, tags: p.tags, @@ -1704,10 +1706,23 @@ export function createApp({ rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])), tags: t.Optional(t.Array(t.String({ maxLength: 64 }), { maxItems: 20 })), listed: t.Optional(t.Boolean()), + category: t.Optional(t.Union([t.Integer({ minimum: 1 }), t.Null()])), reason: t.Optional(t.String({ maxLength: 500 })), }), }, ) + // The categories a product may be filed under. + .get( + "/products/:id/categories", + async ({ me, params }) => { + await creatorOf(params.id); + await need(me, "product:edit", productScope(params.id)); + const [p] = await db.select().from(product).where(eq(product.id, params.id)); + const parent = (await products.latestParent(p!))?.parentInfo?.categories ?? []; + return { choices: await products.categoryChoices(p!), parent }; + }, + { signedIn: true, params: productParam }, + ) // A draft becomes published. .post( "/products/:id/publish", @@ -1787,8 +1802,15 @@ export function createApp({ 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 }, + t.Union([ + t.Literal("name"), + t.Literal("description"), + t.Literal("rating"), + t.Literal("tags"), + t.Literal("listed"), + t.Literal("category"), + ]), + { maxItems: 6 }, ), reason: t.String({ maxLength: 500 }), }), diff --git a/apps/api/src/products.ts b/apps/api/src/products.ts index 26e034a..e093191 100644 --- a/apps/api/src/products.ts +++ b/apps/api/src/products.ts @@ -28,9 +28,20 @@ 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 const infoFields = ["name", "description", "rating", "tags", "listed", "category"] as const; export type InfoField = (typeof infoFields)[number]; -export type InfoInput = { name?: string; description?: string; rating?: string; tags?: string[]; listed?: boolean }; +/** `category`: an IMVU category ID from categoryChoices, or null for its parent's. */ +export type InfoInput = { + name?: string; + description?: string; + rating?: string; + tags?: string[]; + listed?: boolean; + category?: number | null; +}; + +/** An IMVU category path, outermost first. */ +export type CategoryPath = { id: number; name: string }[]; /** What the person editing may do beyond product:edit (from the permission check). */ export type EditRights = { creator: boolean; editLocked: boolean; rateLower: boolean }; @@ -407,7 +418,9 @@ export class Products { if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`)); if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase())); if (opts.category !== undefined) - conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`); + conds.push( + sql`coalesce(${product.category}, ${productRevision.parentInfo}->'categories') @> ${JSON.stringify([{ id: opts.category }])}::jsonb`, + ); const tag = opts.tag !== undefined ? checkTags([opts.tag])?.[0] : undefined; if (opts.tag !== undefined) conds.push(tag ? sql`${product.tags} @> ${JSON.stringify([tag])}::jsonb` : sql`false`); if (opts.catalog) @@ -513,6 +526,15 @@ export class Products { next.tags = tags; } if (input.listed !== undefined) next.listed = input.listed; + if (input.category !== undefined) { + if (input.category === null) next.category = null; + else { + const path = (await this.categoryChoices(p)).find((c) => c.at(-1)!.id === input.category); + if (!path) + throw new ProductError(400, "invalid", "Choose one of the categories offered for this product."); + next.category = path; + } + } const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); const changes: Record = {}; @@ -582,6 +604,26 @@ export class Products { }); } + /** + * The categories a product may be filed under (docs/products.md): every IMVU category path the hub has seen on a + * revision's parent, and each step of those paths, under the same top-level category as its own parent's (any, if + * its parent's is unknown). Keeping the top level keeps it the same kind of product (clothing, furniture, rooms). + */ + async categoryChoices(p: Product): Promise { + const parent = (await this.latestParent(p))?.parentInfo?.categories ?? []; + const seen = await this.db + .selectDistinct({ path: sql`${productRevision.parentInfo}->'categories'` }) + .from(productRevision) + .where(sql`jsonb_array_length(coalesce(${productRevision.parentInfo}->'categories', '[]'::jsonb)) > 0`); + const byId = new Map(); + for (const path of [parent, ...seen.map((s) => s.path)]) + for (let i = 0; i < path.length; i++) + if (!byId.has(path[i]!.id)) byId.set(path[i]!.id, path.slice(0, i + 1).map((c) => ({ id: c.id, name: c.name }))); + const top = parent[0]?.id; + const label = (c: CategoryPath) => c.map((x) => x.name).join(" / "); + return [...byId.values()].filter((c) => top === undefined || c[0]!.id === top).sort((a, b) => label(a).localeCompare(label(b))); + } + /** A product's info history, newest first, with who made each change. */ async history(id: string) { return this.db @@ -610,6 +652,8 @@ export class Products { if (!e || (e.kind !== "edit" && e.kind !== "revert")) throw new ProductError(404, "not_found", "No such change to undo."); const input: Record = {}; for (const f of infoFields) if (e.changes[f]) input[f] = e.changes[f].from; + // Categories are kept as paths; an edit takes the category's ID. + if (Array.isArray(input.category)) input.category = (input.category as CategoryPath).at(-1)?.id ?? null; const done = await this.updateInfo(byId, id, input as InfoInput, reason, rights, e.id); if (done.fields.length === 0) throw new ProductError(409, "unchanged", "Its info is already as it was before that change."); return done; diff --git a/apps/api/test/products.test.ts b/apps/api/test/products.test.ts index edd2596..edf27e6 100644 --- a/apps/api/test/products.test.ts +++ b/apps/api/test/products.test.ts @@ -280,6 +280,28 @@ test("derived products carry their IMVU parent's image and categories", async () expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] }); expect(await hub.products.fillParentInfo(50)).toBe(0); + // A product can be filed under another category the hub has seen, in its parent's top-level category: a jacket + // made from a plain top goes under Coats & Jackets, never under Hair or Body (another kind of product). + imvuProducts.set(7004, { ...coat, category_path: coat.category_path.slice(0, 3) }); + const jacketID = await upload("Dresser Jacket", 7004); + for (let i = 0; i < 100 && (await page(jacketID)).categories.length === 0; i++) await Bun.sleep(20); + const { choices, parent } = (await call(dresser, "GET", `/api/v1/products/${jacketID}/categories`)).json; + expect(parent.map((c: any) => c.id)).toEqual([106, 40, 128]); + expect(choices.map((c: any) => c.at(-1).id).sort()).toEqual([106, 128, 40, 716]); + expect((await call(undefined, "GET", `/api/v1/products/${jacketID}/categories`)).status).toBe(401); + expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 75 })).json.error).toBe("invalid"); + const filed = await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 716 }); + expect(filed.json.fields).toEqual(["category"]); + expect(filed.json.product.categories).toEqual(coat.category_path); + expect(filed.json.product.category).toEqual(coat.category_path); + expect(await names("&category=716")).toEqual(["Dresser Coat", "Dresser Jacket"]); + // Back to its parent's: the history keeps paths, and undoing puts the chosen one back. + expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: null })).json.product.categories).toEqual(parent); + expect(await names("&category=716")).toEqual(["Dresser Coat"]); + const [cleared] = (await call(dresser, "GET", `/api/v1/products/${jacketID}/history`)).json.entries; + expect(cleared.changes.category).toEqual({ from: coat.category_path, to: null }); + expect((await call(dresser, "POST", `/api/v1/products/${jacketID}/history/${cleared.id}/revert`, {})).json.product.categories).toEqual(coat.category_path); + // The library shows them too. expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200); expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image }); diff --git a/apps/web/app/products/[id]/edit/page.tsx b/apps/web/app/products/[id]/edit/page.tsx index 0b127a6..00d42e1 100644 --- a/apps/web/app/products/[id]/edit/page.tsx +++ b/apps/web/app/products/[id]/edit/page.tsx @@ -23,6 +23,7 @@ const fieldNames: Record = { rating: "Rating", tags: "Tags", listed: "Listed", + category: "Category", locked: "Locks", status: "Status", }; @@ -35,7 +36,11 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st 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 [{ entries }, { choices, parent }] = await Promise.all([ + api.products({ id }).history.get().then(unwrap), + api.products({ id }).categories.get().then(unwrap), + ]); + const path = (c: { name: string }[]) => c.map((x) => x.name).join(" › "); const editLocked = can("product:edit-locked"); const locked = (f: string) => product.locked.includes(f) && !editLocked; @@ -122,6 +127,38 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st /> {locked("tags") ? : Up to 10, separated by commas.} + + Category + {locked("category") || choices.length === 0 ? ( +

    {product.categories.length ? path(product.categories) : "None known"}

    + ) : ( + + + {parent.length ? `Its parent's: ${path(parent)}` : "None"} + + {choices + .filter((c) => c.at(-1)!.id !== parent.at(-1)?.id) + .map((c) => ( + + {path(c)} + + ))} + + )} + {locked("category") ? ( + + ) : ( + + Where it's filed in the shop and inventory: any category T5DE has seen, within its parent's + kind of product. + + )} +
    Rating @@ -180,7 +217,7 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
    - {(["name", "description", "rating", "tags", "listed"] as const).map((f) => ( + {(["name", "description", "rating", "tags", "listed", "category"] as const).map((f) => (