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]>
670 lines
27 KiB
TypeScript
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)],
|
|
);
|