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).