Products: carry the IMVU parent's image and categories
CI / test (push) Successful in 49s

Revisions with an IMVU parent store its public info (image, category path, gender)
from IMVU's product API, looked up after registering and in a periodic pass.
Product views gain parent, image and categories; GET /products takes category and
limit. The website shows the image on cards and product pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 01:03:02 -07:00
1 parent d57674d90c
commit 6668b55117
15 files changed
+2026 -29

No files matched your search

+116 -7
View File
@@ -1,7 +1,23 @@
// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do.
import { createHash, timingSafeEqual } from "node:crypto";
import { and, desc, eq, ilike, inArray, ne, or, sql, schema, type Database, type FileEntry } from "@t5de/db";
import {
and,
desc,
eq,
gt,
ilike,
inArray,
isNull,
ne,
or,
sql,
schema,
type Database,
type FileEntry,
type ParentInfo,
} from "@t5de/db";
import { cleanHTML, maxDescription } from "./html";
import type { ImvuClient } from "./imvu";
import type { Signer } from "./keys";
const { catalog, product, productRevision, hosting, library, user } = schema;
@@ -55,10 +71,17 @@ export function checkFiles(files: unknown): FileEntry[] | undefined {
const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
a.length === b.length && a.every((f, i) => f.name === b[i]!.name && f.sha256 === b[i]!.sha256 && f.size === b[i]!.size);
// A parent's info another revision looked up lately is reused rather than asking IMVU again.
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
/** Each product's latest revision (joined on product.latest, for lists). */
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
export class Products {
constructor(
private readonly db: Database,
private readonly signer: Signer,
private readonly imvu?: ImvuClient,
) {}
// ---- catalogs ----
@@ -164,7 +187,7 @@ export class Products {
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
const personId = claims.sub;
return this.db.transaction(async (tx) => {
const made = await this.db.transaction(async (tx) => {
let p: Product;
if (typeof claims.product === "string") {
const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update");
@@ -209,6 +232,62 @@ export class Products {
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
return { product: p.id, revision: number };
});
// 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(() => {});
return made;
}
// ---- parents (the IMVU products revisions derive from) ----
/** Looks up and stores a revision's parent info. Throws if IMVU can't be reached (the info stays null). */
async lookUpParent(productId: string, number: number, parent: number): Promise<ParentInfo | undefined> {
if (!this.imvu || parent <= 0) return undefined;
const since = new Date(Date.now() - parentInfoFresh).toISOString();
const [known] = await this.db
.select({ info: productRevision.parentInfo })
.from(productRevision)
.where(
and(
eq(productRevision.parent, parent),
sql`(${productRevision.parentInfo}->>'checkedAt')::timestamptz > ${since}::timestamptz`,
),
)
.limit(1);
let info = known?.info ?? undefined;
if (!info) {
const p = await this.imvu.product(parent);
info = {
found: p !== null,
image: p?.image ?? null,
categories: p?.categories ?? [],
gender: p?.gender ?? "",
checkedAt: new Date().toISOString(),
};
}
await this.db
.update(productRevision)
.set({ parentInfo: info })
.where(and(eq(productRevision.productId, productId), eq(productRevision.number, number)));
return info;
}
/** Looks up parent info for revisions that have a parent and no info yet, a few at a time. */
async fillParentInfo(limit = 20): Promise<number> {
const due = await this.db
.select({ productId: productRevision.productId, number: productRevision.number, parent: productRevision.parent })
.from(productRevision)
.where(and(gt(productRevision.parent, 0), isNull(productRevision.parentInfo)))
.orderBy(desc(productRevision.createdAt))
.limit(limit);
let n = 0;
for (const r of due) {
try {
if (await this.lookUpParent(r.productId, r.number, r.parent)) n++;
} catch {
// IMVU unavailable: try again next time.
}
}
return n;
}
// ---- products ----
@@ -220,19 +299,41 @@ export class Products {
return p;
}
/** Products, newest first; adult ones only if adult. */
async list(opts: { q?: string; creator?: string; adult: boolean; limit?: number }) {
/**
* 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.
*/
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
const conds = [ne(product.latest, 0)];
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 limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
return this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
.select({
product,
creator: user.displayUsername,
creatorUsername: user.username,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
})
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(...conds))
.orderBy(desc(product.createdAt))
.limit(Math.min(opts.limit ?? 60, 200));
.limit(limit);
}
/** A product's latest revision's parent and parent info (for views of one product). */
async latestParent(p: Product) {
const [r] = await this.db
.select({ parent: productRevision.parent, parentInfo: productRevision.parentInfo })
.from(productRevision)
.where(and(eq(productRevision.productId, p.id), eq(productRevision.number, p.latest)));
return r;
}
async get(id: string, adult: boolean) {
@@ -294,10 +395,18 @@ export class Products {
async library(personId: string) {
return this.db
.select({ product, stayOn: library.stayOn, addedAt: library.addedAt, creator: user.displayUsername })
.select({
product,
stayOn: library.stayOn,
addedAt: library.addedAt,
creator: user.displayUsername,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
})
.from(library)
.innerJoin(product, eq(product.id, library.productId))
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(eq(library.userId, personId))
.orderBy(desc(library.addedAt));
}