Files
t5de-hub/apps/api/src/decorations.ts
T
dhkatzandClaude Opus 5.5 97ce2be569
CI / test (push) Successful in 1m4s
Deleting products: by their creators, and removal by moderation
A creator deletes a product: it's no longer listed and nobody new can get it, while people who have it
keep it (its page, files and library entries stay, and rooms keep showing it), as old revisions stay
available. Moderation removes one everywhere, with a reason: its page and files answer 410, and it
leaves libraries and rooms. Both are on the product's page and in the audit log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:10:24 -07:00

110 lines
4.9 KiB
TypeScript

// Decorations over IMVU rooms (docs/products.md, "Decorations"): T5 products an IMVU room's owner placed in it,
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
// room is realtime.ts's decorated().
import { createHash } from "node:crypto";
import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
import { hostedBase } from "./realtime";
const { roomDecoration, product, productRevision, imvuLink } = schema;
export const maxDecorations = 300;
export const maxDecorationPlacement = 200;
export const maxBatch = 20;
/** An IMVU room instance: "<owner's customer ID>-<n>". */
export const decorationInstance = /^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$/;
/** An error for the person; the routes turn it into a Problem. */
export class DecorationError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const decorationHash = (contents: DecorationItem[]) =>
createHash("sha256").update(JSON.stringify(contents)).digest("hex");
/** The room's owner (its IMVU account), after checking the instance ID; hosted rooms are refused. */
function ownerOf(instance: string): number {
if (!decorationInstance.test(instance)) throw new DecorationError(400, "invalid", "That isn't an IMVU room.");
const owner = Number(instance.slice(0, instance.indexOf("-")));
if (Number(instance.slice(instance.indexOf("-") + 1)) >= hostedBase)
throw new DecorationError(400, "hosted_room", "Hosted rooms keep their own scene.");
return owner;
}
export type DecorationView = { instance: string; contents: DecorationItem[]; hash: string };
export class Decorations {
constructor(private readonly db: Database) {}
/**
* Of these product IDs, those a viewer may see: registered (a revision exists), with a revision that isn't
* retracted, and general unless the viewer counts as adult.
*/
private async visible(ids: string[], adult: boolean): Promise<Set<string>> {
if (ids.length === 0) return new Set();
const rows = await this.db
.select({ id: product.id })
.from(product)
.where(
and(
inArray(product.id, ids),
ne(product.latest, 0),
isNull(product.removedAt),
adult ? undefined : eq(product.rating, "general"),
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
),
);
return new Set(rows.map((r) => r.id));
}
/** Rooms' decorations as a viewer sees them (in the order asked; rooms without any have an empty list). */
async get(instances: string[], adult: boolean): Promise<DecorationView[]> {
for (const i of instances) ownerOf(i);
const rows = instances.length
? await this.db.select().from(roomDecoration).where(inArray(roomDecoration.instanceId, instances))
: [];
const stored = new Map(rows.map((r) => [r.instanceId, r.contents]));
const ok = await this.visible([...new Set(rows.flatMap((r) => r.contents.map((it) => it[1])))], adult);
return instances.map((instance) => {
const contents = (stored.get(instance) ?? []).filter((it) => ok.has(it[1]));
return { instance, contents, hash: decorationHash(contents) };
});
}
/** Sets a room's decorations (an empty list removes them): only one of the room owner's T5DE people may. */
async set(personId: string, instance: string, contents: DecorationItem[]): Promise<void> {
const owner = ownerOf(instance);
const [link] = await this.db
.select()
.from(imvuLink)
.where(and(eq(imvuLink.imvuId, owner), eq(imvuLink.userId, personId)));
if (!link) throw new DecorationError(403, "forbidden", "Only the room's owner can decorate it.");
if (contents.length > maxDecorations)
throw new DecorationError(400, "invalid", `A room holds up to ${maxDecorations} decorations.`);
if (new Set(contents.map((it) => it[0])).size !== contents.length)
throw new DecorationError(400, "invalid", "Each furniture instance ID can be used once.");
const ids = [...new Set(contents.map((it) => it[1]))];
if (ids.length) {
const known = await this.db
.select({ id: product.id })
.from(product)
.where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt)));
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
}
if (contents.length === 0) {
await this.db.delete(roomDecoration).where(eq(roomDecoration.instanceId, instance));
return;
}
await this.db
.insert(roomDecoration)
.values({ instanceId: instance, ownerImvuId: owner, contents })
.onConflictDoUpdate({ target: roomDecoration.instanceId, set: { contents, updatedAt: new Date() } });
}
}