Files
t5de-hub/packages/db/src/schema.ts
T
dhkatz 58a3e70a63
Release / release (push) Successful in 3s
CI / test (push) Successful in 1m13s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 12s
feat: file blocks (#10)
Phase 3, part 2 (`docs/design.md`, "Next"): file blocks. Moderators block a file network-wide by its sha256, so a removed product can't come back under another name or catalog.

**Blocking**
- A file's page (Console → Blocks → a file) shows whether it's blocked and the products using it, so a moderator sees what a block removes before confirming ("Block and remove 2 products").
- Moderators reach it from a product's files (each has a Block link for them) or by pasting a sha256 into the Blocks tab, which also lists blocked files.
- Blocking a file:
  - removes every product with a revision using it. Each removal is audited as `product.remove` with the file, so creators are notified and can appeal it as before.
  - puts a `{type: "block", sha256}` change in the changes feed of every catalog hosting it, meaning: delete it whatever uses it. (t5de-catalog doesn't read the feed yet.)
  - refuses uploads with it from then on (409 `blocked`). Each attempt is audited as `file.blocked_upload` with the person and catalog, so moderators see who tried.
- Unblocking lets the file in again. The products it removed stay removed until a moderator restores them, one by one.
- Who: `product:remove` hub-wide (moderators and admins). The Blocks tab shows only for them.

**API**: `GET /api/v1/blocks`, and `GET`/`PUT`/`DELETE /api/v1/blocks/:sha256` (`docs/products.md`).

**Migration** `0013_file_blocks`: the `file_block` table, and a GIN index on revisions' file lists, so finding the products using a file doesn't scan every revision.

**Checks**
- 53 API tests pass. The new `blocks.test.ts` covers:
  - who may block
  - the preview of what a block removes
  - removal of every product using the file, across catalogs and creators
  - the changes feed, notifications and appeals
  - refused uploads and their audit entry
  - unblocking, and restoring a removed product afterwards
- Type checks pass.
- On a local hub, through the website's own forms:
  - the Block link on a product's files, for moderators only
  - the Blocks tab, for moderators only (404 for the creator)
  - the file's page, then blocking: the product is gone, its creator notified and offered Appeal
  - a re-upload through the catalog API: refused, and audited
  - unblocking, then Restore
  - Fixed along the way: an empty reason gave a generic "Some of that isn't right"; it now says "Give a reason."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 3, part 2 (`docs/design.md`, "Next"): file blocks. Moderators block a file network-wide by its sha256, so a removed product can't come back under another name or catalog.

**Blocking**
- A file's page (Console → Blocks → a file) shows whether it's blocked and the products using it, so a moderator sees what a block removes before confirming ("Block and remove 2 products").
- Moderators reach it from a product's files (each has a Block link for them) or by pasting a sha256 into the Blocks tab, which also lists blocked files.
- Blocking a file:
  - removes every product with a revision using it. Each removal is audited as `product.remove` with the file, so creators are notified and can appeal it as before.
  - puts a `{type: "block", sha256}` change in the changes feed of every catalog hosting it, meaning: delete it whatever uses it. (t5de-catalog doesn't read the feed yet.)
  - refuses uploads with it from then on (409 `blocked`). Each attempt is audited as `file.blocked_upload` with the person and catalog, so moderators see who tried.
- Unblocking lets the file in again. The products it removed stay removed until a moderator restores them, one by one.
- Who: `product:remove` hub-wide (moderators and admins). The Blocks tab shows only for them.

**API**: `GET /api/v1/blocks`, and `GET`/`PUT`/`DELETE /api/v1/blocks/:sha256` (`docs/products.md`).

**Migration** `0013_file_blocks`: the `file_block` table, and a GIN index on revisions' file lists, so finding the products using a file doesn't scan every revision.

**Checks**
- 53 API tests pass. The new `blocks.test.ts` covers:
  - who may block
  - the preview of what a block removes
  - removal of every product using the file, across catalogs and creators
  - the changes feed, notifications and appeals
  - refused uploads and their audit entry
  - unblocking, and restoring a removed product afterwards
- Type checks pass.
- On a local hub, through the website's own forms:
  - the Block link on a product's files, for moderators only
  - the Blocks tab, for moderators only (404 for the creator)
  - the file's page, then blocking: the product is gone, its creator notified and offered Appeal
  - a re-upload through the catalog API: refused, and audited
  - unblocking, then Restore
  - Fixed along the way: an empty reason gave a generic "Some of that isn't right"; it now says "Give a reason."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 22:48:07 +00:00

670 lines
27 KiB
TypeScript

// The hub's tables: Better Auth's (people, sessions, passwords, signing keys, device sign-ins) and the hub's own
// (linked IMVU accounts, link codes, the audit log). Better Auth's are as its CLI generates them for the plugins
// used (username, admin, device-authorization, bearer, jwt), with the hub's linkCap added to user.
import { sql } from "drizzle-orm";
import {
bigint,
boolean,
customType,
index,
integer,
jsonb,
pgTable,
primaryKey,
serial,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
const createdAt = () => timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
const updatedAt = () =>
timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date());
// A person: one T5DE account.
export const user = pgTable(
"user",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
createdAt: createdAt(),
updatedAt: updatedAt(),
username: text("username").unique(),
displayUsername: text("display_username"),
role: text("role").notNull().default("user"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires", { withTimezone: true }),
// How many IMVU accounts the person may link; moderators raise it on request.
linkCap: integer("link_cap").notNull().default(10),
// False while a new account waits for a moderator's approval (registration closed); it can only sign in.
approved: boolean("approved").notNull().default(true),
},
(t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)],
);
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
token: text("token").notNull().unique(),
createdAt: createdAt(),
updatedAt: updatedAt(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
},
(t) => [index("session_user").on(t.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
scope: text("scope"),
password: text("password"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("account_user").on(t.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("verification_identifier").on(t.identifier)],
);
export const jwks = pgTable("jwks", {
id: text("id").primaryKey(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
createdAt: createdAt(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
alg: text("alg"),
crv: text("crv"),
});
// A T5DE proxy signing in: it shows userCode, the person approves it on the website.
export const deviceCode = pgTable("device_code", {
id: text("id").primaryKey(),
deviceCode: text("device_code").notNull(),
userCode: text("user_code").notNull(),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
status: text("status").notNull(),
lastPolledAt: timestamp("last_polled_at", { withTimezone: true }),
pollingInterval: integer("polling_interval"),
clientId: text("client_id"),
scope: text("scope"),
createdAt: createdAt(),
updatedAt: updatedAt(),
});
// A linked IMVU account. Linking it again from another person moves it (the most recent proof wins).
export const imvuLink = pgTable(
"imvu_link",
{
imvuId: bigint("imvu_id", { mode: "number" }).primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// What catalogs and rooms see instead of the person, so a person's accounts can't be connected.
pairwiseId: text("pairwise_id").notNull().unique(),
username: text("username").notNull(),
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
showBadge: boolean("show_badge").notNull().default(false),
showPresence: boolean("show_presence").notNull().default(false),
listRooms: boolean("list_rooms").notNull().default(false),
},
(t) => [index("imvu_link_user").on(t.userId)],
);
// An IMVU room a T5DE user entered whose owner is a linked account, as IMVU last described it. Listed on the hub
// only if its owner chose to list their rooms and IMVU says it's public and listed.
export const room = pgTable(
"room",
{
instanceId: text("instance_id").primaryKey(), // "<owner>-<n>"
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
name: text("name").notNull(),
description: text("description").notNull().default(""),
imageURL: text("image_url").notNull().default(""),
privacy: text("privacy").notNull(),
type: text("type").notNull(),
contentRating: text("content_rating").notNull().default(""),
accessPass: boolean("access_pass").notNull().default(false),
occupancy: integer("occupancy").notNull().default(0),
capacity: integer("capacity").notNull().default(0),
expired: boolean("expired").notNull().default(false),
checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(),
seenAt: timestamp("seen_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("room_owner").on(t.ownerImvuId)],
);
// An age check by one provider: of a person, or of one of their linked IMVU accounts. Only the outcome: never a
// birth date, document, photo or name (providers that see those keep them).
export const ageCheck = pgTable(
"age_check",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Set when the check is of one IMVU account (IMVU's own verification); null when it's of the person.
imvuId: bigint("imvu_id", { mode: "number" }),
provider: text("provider").notNull(), // "imvu", "didit", "wallet", ...
method: text("method").notNull().default(""), // e.g. "imvu_age_verified", "id_document", "wallet_mdoc"
status: text("status").notNull(), // "pending", "complete", "failed", "revoked"
overEighteen: boolean("over_eighteen").notNull().default(false),
providerRef: text("provider_ref"), // the provider's session or transaction ID
createdAt: createdAt(),
checkedAt: timestamp("checked_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
},
(t) => [
index("age_check_user").on(t.userId),
index("age_check_imvu").on(t.imvuId),
uniqueIndex("age_check_provider_ref").on(t.provider, t.providerRef),
],
);
// A code the person is to put in an IMVU account's tagline, proving it's theirs.
export const linkChallenge = pgTable(
"link_challenge",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
username: text("username").notNull(),
code: text("code").notNull(),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.imvuId] })],
);
export const audit = pgTable(
"audit",
{
id: serial("id").primaryKey(),
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
actorId: text("actor_id").references(() => user.id, { onDelete: "set null" }),
action: text("action").notNull(),
target: text("target").notNull().default(""),
detail: jsonb("detail").$type<Record<string, unknown>>().notNull().default({}),
// The resources the change touches ({catalog, product, room, person}): who may see the entry (docs/console.md).
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
},
(t) => [index("audit_at").on(t.at), index("audit_tags").using("gin", t.tags)],
);
/** The resources an audit entry touches. */
export type AuditTags = { catalog?: string; product?: string; room?: string; person?: string };
// ---- Products (docs/products.md) ----
export type FileEntry = { name: string; sha256: string; size: number };
/** The public info of the IMVU product a revision derives from (IMVU's API), when it was looked up. */
export type ParentInfo = {
found: boolean; // false: IMVU shows no such product publicly
image: string | null; // https, on *.imvu.com
categories: { id: number; name: string }[]; // outermost first
gender: string; // "female", "male" or ""
checkedAt: string; // ISO time
};
// The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used.
export const signingKey = pgTable("signing_key", {
id: text("id").primaryKey(), // the JWK's kid
publicKey: jsonb("public_key").$type<Record<string, string>>().notNull(),
privateKey: jsonb("private_key").$type<Record<string, string>>().notNull(),
createdAt: createdAt(),
});
// A catalog: a server hosting product files. Hidden until a moderator approves it.
export const catalog = pgTable(
"catalog",
{
id: text("id").primaryKey(),
ownerId: text("owner_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
url: text("url").notNull(), // its public URL
contact: text("contact").notNull(), // takedown and legal contact
visibility: text("visibility").notNull().default("public"), // "public", "private"
membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected", "suspended"
statusReason: text("status_reason").notNull().default(""), // why it was rejected or suspended
maxRating: text("max_rating").notNull().default("adult"), // the highest rating it hosts: "general", "adult"
uploads: text("uploads").notNull().default("anyone"), // who may upload: "anyone", "members", "team"
mirrors: text("mirrors").notNull().default("approve"), // mirroring others' revisions: "auto", "approve", "off"
keyHash: text("key_hash").notNull(), // sha256 of its key, hex
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex("catalog_url").on(t.url)],
);
// A role given to a person at a scope (docs/console.md). Hub-wide roles are user.role; owners are derived (a
// catalog's owner_id, a product's creator_id), so they're never here.
export const roleAssignment = pgTable(
"role_assignment",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").notNull(), // e.g. "catalog-operator"
scopeType: text("scope_type").notNull(), // "catalog"
scopeId: text("scope_id").notNull(),
grantedBy: text("granted_by").references(() => user.id, { onDelete: "set null" }),
createdAt: createdAt(),
},
(t) => [
primaryKey({ columns: [t.userId, t.role, t.scopeType, t.scopeId] }),
index("role_assignment_scope").on(t.scopeType, t.scopeId),
],
);
// A person's membership of a catalog: asked to join, a member, or banned from it.
export const catalogMember = pgTable(
"catalog_member",
{
catalogId: text("catalog_id")
.notNull()
.references(() => catalog.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
status: text("status").notNull(), // "requested", "member", "banned"
invite: text("invite"), // the invite code they joined with
decidedBy: text("decided_by").references(() => user.id, { onDelete: "set null" }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [primaryKey({ columns: [t.catalogId, t.userId] }), index("catalog_member_user").on(t.userId)],
);
// An invitation to join a catalog: a code, used up to maxUses times (null: no limit) until it expires.
export const catalogInvite = pgTable(
"catalog_invite",
{
code: text("code").primaryKey(),
catalogId: text("catalog_id")
.notNull()
.references(() => catalog.id, { onDelete: "cascade" }),
createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }),
maxUses: integer("max_uses"),
uses: integer("uses").notNull().default(0),
expiresAt: timestamp("expires_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [index("catalog_invite_catalog").on(t.catalogId)],
);
// What a catalog must act on (GET /api/v1/catalog/changes): files to delete, revisions to mirror, its status.
export const catalogChange = pgTable(
"catalog_change",
{
id: serial("id").primaryKey(),
catalogId: text("catalog_id")
.notNull()
.references(() => catalog.id, { onDelete: "cascade" }),
data: jsonb("data").$type<Record<string, unknown>>().notNull(), // {type, ...}
createdAt: createdAt(),
},
(t) => [index("catalog_change_catalog").on(t.catalogId, t.id)],
);
// A product: its info, owned by its creator. Its files are in revisions.
export const product = pgTable(
"product",
{
id: text("id").primaryKey(), // 10 of a-z2-7
creatorId: text("creator_id")
.notNull()
.references(() => user.id, { onDelete: "restrict" }),
name: text("name").notNull(),
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<string[]>().notNull().default([]),
// The IMVU category path its creator (or a moderator) chose, outermost first; null: its parent's.
category: jsonb("category").$type<{ id: number; name: string }[]>(),
// Info fields a moderator locked: only people with product:edit-locked change them.
locked: jsonb("locked").$type<string[]>().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).
removedAt: timestamp("removed_at", { withTimezone: true }),
removedReason: text("removed_reason"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(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<Record<string, InfoChange>>().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).
export const productRevision = pgTable(
"product_revision",
{
productId: text("product_id")
.notNull()
.references(() => product.id, { onDelete: "cascade" }),
number: integer("number").notNull(),
files: jsonb("files").$type<FileEntry[]>().notNull(),
rating: text("rating").notNull(),
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
parentInfo: jsonb("parent_info").$type<ParentInfo>(), // null until looked up (parent > 0)
notes: text("notes").notNull().default(""),
token: text("token").notNull(), // the hub's t5de-revision token
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
retracted: boolean("retracted").notNull().default(false),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.productId, t.number] }), index("product_revision_files").using("gin", t.files)],
);
// A file blocked network-wide by its sha256: revisions with it can't be registered, products using it are removed,
// and catalogs are told to delete it (docs/products.md).
export const fileBlock = pgTable("file_block", {
sha256: text("sha256").primaryKey(),
reason: text("reason").notNull(),
byId: text("by_id").references(() => user.id, { onDelete: "set null" }),
createdAt: createdAt(),
});
// Which catalogs host which revisions.
export const hosting = pgTable(
"hosting",
{
catalogId: text("catalog_id")
.notNull()
.references(() => catalog.id, { onDelete: "cascade" }),
productId: text("product_id").notNull(),
number: integer("number").notNull(),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.catalogId, t.productId, t.number] }), index("hosting_product").on(t.productId)],
);
// A person's library: products they got, optionally staying on a revision.
export const library = pgTable(
"library",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
productId: text("product_id")
.notNull()
.references(() => product.id, { onDelete: "cascade" }),
stayOn: integer("stay_on"),
addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.productId] })],
);
// ---- T5DE-hosted rooms (docs/hosted-rooms.md) ----
/** A scene item, as the client's room_contents: [furniture instance ID, product ID (IMVU's or the hub's), placement]. */
export type SceneItem = [number, number | string, string];
// A room hosted by the hub: its instance ID is "2100000000-<n>", its chat ID 2100000000 + n. Who's in it is kept in
// memory only (apps/api/src/realtime.ts). Closing it keeps the row, so its number is never reused.
export const hostedRoom = pgTable(
"hosted_room",
{
n: serial("n").primaryKey(),
ownerId: text("owner_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// One of the owner's linked IMVU accounts, shown as the room's owner (the client lets it set the scene).
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
name: text("name").notNull(),
description: text("description").notNull().default(""), // plain text
roomPid: bigint("room_pid", { mode: "number" }).notNull(), // the room shell, an IMVU product
rating: text("rating").notNull().default("general"), // "general", "adult"
maxUsers: integer("max_users").notNull().default(10),
scene: jsonb("scene").$type<SceneItem[]>().notNull().default([]),
sceneHash: text("scene_hash").notNull().default(""),
closed: boolean("closed").notNull().default(false),
// Phase 2 (docs/hosted-rooms.md): "public" or "private" (unlisted; joined by the owner, moderators and invitees).
privacy: text("privacy").notNull().default("public"),
// Up to 20 IMVU accounts that may boot and ban (and invite to a private room), with their names as known then.
moderators: jsonb("moderators").$type<RoomModerator[]>().notNull().default([]),
// The room's image (hosted_room_image), by the sha256 of its bytes; null: none.
imageHash: text("image_hash"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)],
);
/** A hosted room's moderator: an IMVU account, and its name as known when it was added ("" if unknown). */
export type RoomModerator = { imvuId: number; username: string };
const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({
dataType: () => "bytea",
fromDriver: (v) => Buffer.from(v),
});
// A hosted room's image, as uploaded (PNG or JPEG, checked by its magic bytes, up to 512 KB).
export const hostedRoomImage = pgTable("hosted_room_image", {
n: integer("n")
.primaryKey()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
contentType: text("content_type").notNull(), // "image/png", "image/jpeg"
data: bytea("data").notNull(),
hash: text("hash").notNull(), // sha256, hex
updatedAt: updatedAt(),
});
// IMVU accounts banned from a hosted room; until null: for good.
export const hostedRoomBan = pgTable(
"hosted_room_ban",
{
n: integer("n")
.notNull()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
byImvuId: bigint("by_imvu_id", { mode: "number" }).notNull(),
until: timestamp("until", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.n, t.imvuId] })],
);
// A person's favorite hosted rooms.
export const hostedRoomFavorite = pgTable(
"hosted_room_favorite",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
n: integer("n")
.notNull()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.n] })],
);
// A person's rating of a hosted room, 1-5.
export const hostedRoomRating = pgTable(
"hosted_room_rating",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
n: integer("n")
.notNull()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
score: integer("score").notNull(),
updatedAt: updatedAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_rating_room").on(t.n)],
);
// A person's latest visit to each hosted room (room.join), with the IMVU account they joined as. 50 are kept.
export const hostedRoomVisit = pgTable(
"hosted_room_visit",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
n: integer("n")
.notNull()
.references(() => hostedRoom.n, { onDelete: "cascade" }),
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_visit_at").on(t.userId, t.at)],
);
// ---- Decorations over IMVU rooms (docs/products.md, "Decorations") ----
/** A decoration: [furniture instance ID, hub product ID, placement], as the client's room_contents. */
export type DecorationItem = [number, string, string];
// T5 products placed in an IMVU room by its owner. IMVU keeps the room without them (T5DE's proxy strips them from
// what the client saves); the proxies of T5DE users in the room add them back. Hosted rooms keep theirs in
// hosted_room.scene instead.
export const roomDecoration = pgTable(
"room_decoration",
{
instanceId: text("instance_id").primaryKey(), // IMVU's room instance, "<owner>-<n>"
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(), // the number before the dash
contents: jsonb("contents").$type<DecorationItem[]>().notNull(),
updatedAt: updatedAt(),
},
(t) => [index("room_decoration_owner").on(t.ownerImvuId)],
);
// The hub's settings that admins change from the console (e.g. registration: "open" or "closed").
export const hubSetting = pgTable("hub_setting", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: updatedAt(),
});
// A case: something someone has to decide (docs/console.md, "Cases"): a report, a rating review, a removal request,
// a catalog's deletion request, or an appeal. ("case" is an SQL keyword, hence the table's name.)
export const moderationCase = pgTable(
"moderation_case",
{
id: serial("id").primaryKey(),
kind: text("kind").notNull(), // "report", "rating-review", "removal-request", "catalog-deletion", "appeal"
status: text("status").notNull().default("open"), // "open", "resolved", "declined"
// What it's about ({catalog, product, room, person}), as audit entries are tagged.
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
openedBy: text("opened_by").references(() => user.id, { onDelete: "set null" }),
assignee: text("assignee").references(() => user.id, { onDelete: "set null" }),
reason: text("reason").notNull().default(""), // why it was opened
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}), // per kind (an appeal's audit entry)
resolution: text("resolution").notNull().default(""),
decidedBy: text("decided_by").references(() => user.id, { onDelete: "set null" }),
decidedAt: timestamp("decided_at", { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("moderation_case_status").on(t.status, t.id),
index("moderation_case_opened_by").on(t.openedBy),
index("moderation_case_tags").using("gin", t.tags),
],
);
// A case's thread. Internal notes are only for those deciding it.
export const caseNote = pgTable(
"case_note",
{
id: serial("id").primaryKey(),
caseId: integer("case_id")
.notNull()
.references(() => moderationCase.id, { onDelete: "cascade" }),
byId: text("by_id").references(() => user.id, { onDelete: "set null" }),
body: text("body").notNull(),
internal: boolean("internal").notNull().default(false),
createdAt: createdAt(),
},
(t) => [index("case_note_case").on(t.caseId, t.id)],
);
// Something for a person to know: a moderator edited their product, their case was decided, ... (docs/console.md).
export const notification = pgTable(
"notification",
{
id: serial("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
type: text("type").notNull(),
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
createdAt: createdAt(),
readAt: timestamp("read_at", { withTimezone: true }),
},
(t) => [index("notification_user").on(t.userId, t.id)],
);