Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
CI / test (push) Successful in 1m10s

Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot
and ban over realtime (room.boot); bans are listed and lifted over REST. Private
rooms are unlisted and joined by the owner, moderators and holders of an accepted
invite (room.invite / room.invited / room.invite_reply / room.invite_answer).
Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database
and served with cache headers. Per-person favorites, ratings and recent rooms.
room.watch pushes room.occupancy; participants carry their IMVU name, and
GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 06:07:41 -07:00
1 parent 43183ec7ee
commit 76c8e0342e
16 files changed
+3851 -73

No files matched your search

+88
View File
@@ -5,6 +5,7 @@ import { sql } from "drizzle-orm";
import {
bigint,
boolean,
customType,
index,
integer,
jsonb,
@@ -356,12 +357,99 @@ export const hostedRoom = pgTable(
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. */