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:
1 parent
d57674d90c
commit
6668b55117
15 files changed
+2026
-29
No files matched your search
+116
-7
@@ -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));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user