From f2c16f198a0ade11b9f1a8c43966d7b1b18b8355 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 26 Oct 2026 12:30:22 -0400 Subject: [PATCH] [rooms] only show confirmed role invites --- apps/rooms/src/rooms.app.ts | 122 ++++++++++++++------ apps/rooms/src/test/integration/api.test.ts | 99 ++++++++++++++-- packages/domain/src/rooms-db.ts | 52 ++++++++- 3 files changed, 222 insertions(+), 51 deletions(-) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 4eacd4f..fae9de2 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -14,6 +14,7 @@ import { canManageRoomById, cloneRoom, cloneSubRoom, + confirmedRoomRoles, countRoomsByCreator, createNotification, createStudioRoom, @@ -83,6 +84,7 @@ import { transferRoomOwnership, unbanPlayerFromRoom, updateRoomFields, + withConfirmedRoles, writeAuditLog, } from '@repo/domain' import { @@ -665,16 +667,14 @@ function parseMaxPlayerCalculationMode(value: unknown): number | undefined { return Object.values(MAX_PLAYER_CALCULATION_MODES).includes(ordinal) ? ordinal : undefined } -async function pushRoomUpdate( - c: Context, - playerId: number, - room: Record -): Promise { +async function pushRoomUpdate(c: Context, playerId: number, room: Room): Promise { try { + // The pushed room is the client's room, like any served one: pending invites are not + // part of its `Roles` (see `serveRoom`). await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( playerId, NotificationType.SubscriptionUpdateRoom, - room + withConfirmedRoles(room) ) } catch (err) { logger.error('failed to push RoomUpdate notification', { @@ -702,7 +702,7 @@ async function pushRoomUpdate( async function pushRoomUpdateToRoom( c: Context, roomId: number, - room: Record, + room: Room, alsoNotify: number[] = [] ): Promise { const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId)) @@ -1114,16 +1114,44 @@ function toSaveDetail(save: Record, target: number | null) { } } -/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */ -function roomEnvelope(c: Context, value: unknown, error = '') { - return c.json({ success: error === '', error, value }) +/** + * A room as the client is shown it. The stored room is served whole EXCEPT for its pending + * role invites: a `Roles` entry with `Role` 0 and an `InvitedRole` is an offer the player + * has not answered, and served as-is it put the invited player on the room's member list + * (and, on the 2025 client, in its co-owner list) before they had accepted anything. The + * offer reaches them as a `RoomCoOwnerInvited` message; the entry stays in the STORED room + * for the accept to promote, which is why this is applied where the room leaves the worker + * and never to a room about to be written back. + * + * Every room this worker answers — alone, in a list, in the mutation envelope or pushed as + * a `RoomUpdate` — goes through this (or {@link serveRooms}). + */ +const serveRoom = withConfirmedRoles + +/** {@link serveRoom} over a list. */ +const serveRooms = (rooms: Room[]): Room[] => rooms.map(serveRoom) + +/** {@link serveRoom} over a `{ Results, TotalResults }` page. */ +const serveRoomPage = (page: T): T => ({ + ...page, + Results: serveRooms(page.Results), +}) + +/** + * Client envelope for room mutations: `{ success, error, value }` (lowercase). `value` is + * the updated room (served through {@link serveRoom}) or null on a refusal. + */ +function roomEnvelope(c: Context, value: Room | null, error = '') { + return c.json({ success: error === '', error, value: value && serveRoom(value) }) } /** * The same envelope for the ban write, whose `value` is the BAN rather than the room — * a ban isn't part of the room the client renders, so there is no updated room to send. */ -const banEnvelope = roomEnvelope +function banEnvelope(c: Context, value: unknown, error = '') { + return c.json({ success: error === '', error, value }) +} /** * The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying @@ -1162,7 +1190,7 @@ const experienceEnvelope = leaderboardEnvelope async function ownedRooms(c: Context) { const accountId = await authedAccountId(c) if (accountId === null) return unauthorized(c) - return c.json(await getRoomsByCreator(c.env.DB, accountId)) + return c.json(serveRooms(await getRoomsByCreator(c.env.DB, accountId))) } /** @@ -1175,7 +1203,7 @@ async function ownedRoomsExcludingDorm(c: Context) { const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) => canManageRoom(room, accountId) ) - return c.json(rooms.filter((r) => r.IsDorm !== true)) + return c.json(serveRooms(rooms.filter((r) => r.IsDorm !== true))) } /** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */ @@ -1331,10 +1359,10 @@ const app = new Hono() if (idParam) { const id = firstId(idParam) const room = id === undefined ? null : await getRoomById(c.env.DB, id) - return c.json(room ?? (c.req.query('include') === '525581' ? null : {})) + return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {}) } const room = await getRoomByName(c.env.DB, nameParam ?? '') - return c.json(room ?? (c.req.query('include') === '525581' ? null : {})) + return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {}) } ) @@ -1366,7 +1394,7 @@ const app = new Hono() return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403) const room = await createStudioRoom(c.env.DB, name, accountId, version) if (!room) return c.json({ error: 'A room with that name already exists.' }, 409) - return c.json(room) + return c.json(serveRoom(room)) } ) @@ -1404,7 +1432,7 @@ const app = new Hono() const query = c.req.query('query') ?? '' const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30 - return c.json(await searchRooms(c.env.DB, query, skip, take)) + return c.json(serveRoomPage(await searchRooms(c.env.DB, query, skip, take))) } ) @@ -1489,7 +1517,7 @@ const app = new Hono() const tag = c.req.query('tag') ?? '' const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await getHotRooms(c.env.DB, tag, skip, take)) + return c.json(serveRoomPage(await getHotRooms(c.env.DB, tag, skip, take))) } ) @@ -1541,7 +1569,7 @@ const app = new Hono() async (c) => { const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await getTrendingRooms(c.env.DB, skip, take)) + return c.json(serveRoomPage(await getTrendingRooms(c.env.DB, skip, take))) } ) @@ -1563,7 +1591,7 @@ const app = new Hono() async (c) => { const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await getBaseRooms(c.env.DB, skip, take)) + return c.json(serveRooms(await getBaseRooms(c.env.DB, skip, take))) } ) @@ -1591,7 +1619,7 @@ const app = new Hono() async (c) => { const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await getRecommendedRooms(c.env.DB, skip, take)) + return c.json(serveRooms(await getRecommendedRooms(c.env.DB, skip, take))) } ) @@ -1686,13 +1714,15 @@ const app = new Hono() if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c) const rooms = await getRoomsByIds(c.env.DB, ids) return c.json( - excludePrivateRooms(c.req.query('excludePrivateRooms')) - ? rooms.filter((r) => r.Accessibility === 1) - : rooms + serveRooms( + excludePrivateRooms(c.req.query('excludePrivateRooms')) + ? rooms.filter((r) => r.Accessibility === 1) + : rooms + ) ) } const room = await getRoomByName(c.env.DB, nameParam ?? '') - return c.json(room ? [room] : []) + return c.json(room ? [serveRoom(room)] : []) } ) @@ -1744,7 +1774,7 @@ const app = new Hono() const rooms = await getRoomsByIds(c.env.DB, ids) const excludePrivate = excludePrivateRooms(field('excludePrivateRooms')[0]) - return c.json(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms) + return c.json(serveRooms(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms)) } ) @@ -1835,7 +1865,7 @@ const app = new Hono() async (c) => { const accountId = await authedAccountId(c) if (accountId === null) return unauthorized(c) - return c.json(await getContributedRooms(c.env.DB, accountId)) + return c.json(serveRooms(await getContributedRooms(c.env.DB, accountId))) } ) @@ -1892,7 +1922,11 @@ const app = new Hono() responses: { 200: json(RoomDto.array(), 'That account’s public rooms') }, }), async (c) => - c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10))) + c.json( + serveRooms( + await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)) + ) + ) ) // A player's showcased rooms — the hand-picked rail the client draws on a profile, @@ -1940,7 +1974,7 @@ const app = new Hono() if (accountId === null) return unauthorized(c) const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take)) + return c.json(serveRooms(await getFavoritedRooms(c.env.DB, accountId, skip, take))) } ) @@ -1964,7 +1998,7 @@ const app = new Hono() if (accountId === null) return unauthorized(c) const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take)) + return c.json(serveRooms(await getVisitedRooms(c.env.DB, accountId, skip, take))) } ) @@ -2010,9 +2044,11 @@ const app = new Hono() const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 return c.json( - own - ? await getVisitedRooms(c.env.DB, playerId, skip, take) - : await getPublicVisitedRooms(c.env.DB, playerId, skip, take) + serveRooms( + own + ? await getVisitedRooms(c.env.DB, playerId, skip, take) + : await getPublicVisitedRooms(c.env.DB, playerId, skip, take) + ) ) } ) @@ -2627,6 +2663,12 @@ const app = new Hono() 'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the', 'room is: a role list says who runs a room, which the room page already shows.', '', + 'Held roles only. A player with a standing co-owner invite they have not answered', + '(`Role` 0, the offer on `InvitedRole`) is not listed here or on the room DTO: they', + 'have no standing in the room until they accept, and the offer reaches them as a', + '`RoomCoOwnerInvited` message. A member who holds a role AND has a higher one pending', + 'is listed at the role they hold, `InvitedRole` included.', + '', 'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An', 'unknown room is an empty list rather than an error — it reads the same as a room', 'nobody holds a role in.', @@ -2639,7 +2681,7 @@ const app = new Hono() async (c) => { const roomId = Number.parseInt(c.req.param('roomId'), 10) const room = await getRoomById(c.env.DB, roomId) - return c.json(room ? roomRoles(room) : []) + return c.json(room ? confirmedRoomRoles(room) : []) } ) @@ -4311,7 +4353,7 @@ const app = new Hono() return c.json({ success: true, error: null, - value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) }, + value: { room: serveRoom(result.room), subRoomDataSave: toSaveResponse(result.save) }, }) } ) @@ -4862,7 +4904,9 @@ const app = new Hono() const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 return c.json( - await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take) + serveRoomPage( + await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take) + ) ) } ) @@ -5130,7 +5174,9 @@ const app = new Hono() tags: ['Rooms'], summary: 'A room by id', description: [ - 'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an', + 'The room as stored, with its `SubRooms` re-attached and pending role invites left', + 'out of `Roles` (an unanswered invite is `Role` 0 with the offer on `InvitedRole`; the', + 'invited player is not a member until they accept). Unlike `GET /rooms?id=`, an', 'unknown room here is a 404, not `{}`. A save that has a Rec Room Studio build', 'lists its main bundles on `CurrentSave.UnitySubAssets` (`Target` 0 Windows, 2', 'Android/Quest, `Filename` downloaded from the CDN at `/unityasset/{filename}`).', @@ -5153,7 +5199,7 @@ const app = new Hono() const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)) if (!room) return c.notFound() filterRoomUnityAssets(room, unityAssetTargetQuery(c)) - return c.json(room) + return c.json(serveRoom(room)) } ) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 0621670..aa60f2a 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -9,6 +9,7 @@ import { import { beforeAll, describe, expect, it } from 'vitest' import { + answerRoomRoleInvite, AUDIT_LOG_SCHEMA_DDL, countRoomsUsingInvention, createRoomInstance, @@ -540,6 +541,20 @@ describe('rooms endpoints', () => { Roles: [{ AccountId: 822, Role: 30 }], }) await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] }) + // ...and one where 820 has only been INVITED to co-own — `Role` 0 with the offer on + // `InvitedRole`. They have no job in the room until they accept, so it stays out of + // both of their lists, and the pending entry is not served as part of the room. + await seed({ + RoomId: 30408, + Name: 'ContribInvited', + CreatorAccountId: 821, + Accessibility: 1, + SubRooms: [], + Roles: [ + { AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }, + { AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 }, + ], + }) const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, { headers: await bearer('820'), @@ -554,6 +569,44 @@ describe('rooms endpoints', () => { expect(rooms.some((r) => r.RoomId === 30407)).toBe(false) expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) }) + // The unanswered invite lists the room for NOBODY: not under "contributed" (above), + // not under "owned". + const owned = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('820') }) + const ownedIds = ((await owned.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId) + expect(ownedIds).toContain(30401) + expect(ownedIds).not.toContain(30408) + + // Nor is the invited player part of the room anyone is shown: the room DTO and the + // role list carry only HELD roles. The stored room keeps the entry for the accept. + type Served = { Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> } + const servedRoles = async () => + ((await (await SELF.fetch(`${ORIGIN}/rooms/30408`)).json()) as Served).Roles + expect(await servedRoles()).toEqual([ + { AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }, + ]) + expect(await (await SELF.fetch(`${ORIGIN}/rooms/30408/roles`)).json()).toEqual([ + { AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }, + ]) + expect( + await env.DB.prepare( + 'SELECT role, invited_role FROM room_role WHERE room_id = 30408 AND account_id = 820' + ).first() + ).toEqual({ role: 0, invited_role: 30 }) + + // Accepting is what puts them in: the room appears in both lists and the entry is served. + const invitedRoom = (await getRoomById(env.DB, 30408))! + expect(await answerRoomRoleInvite(env.DB, 30408, 820, 30, invitedRoom)).not.toBeNull() + expect(await contributed('820')).toContain(30408) + const ownedAfter = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { + headers: await bearer('820'), + }) + expect(((await ownedAfter.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)).toContain( + 30408 + ) + expect(await servedRoles()).toContainEqual( + expect.objectContaining({ AccountId: 820, Role: 30, InvitedRole: 0 }) + ) + // A player who contributes to nothing gets an empty array, not a 404. const none = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, { headers: await bearer('829'), @@ -579,8 +632,8 @@ describe('rooms endpoints', () => { // The DB is shared across this file, and these are the only player-made public rooms // in it — leaving them behind changes what the `new`/`community` room feeds serve. - await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run() - await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30407').run() + await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30408').run() + await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30408').run() }) it('GET /rooms/:roomId/experience serves the room’s stored XP settings, no auth', async () => { @@ -1675,6 +1728,19 @@ describe('rooms endpoints', () => { type RoomEnv = { success: boolean; error: string; value: Record | null } const envOf = async (res: Response) => (await res.json()) as RoomEnv + // A role entry as STORED, read off the `room_role` index — pending invites included. + // The served room and `GET /rooms/:id/roles` carry only held roles, so an entry that is + // nothing but an unanswered invite (`Role` 0) can only be observed here. + type StoredRole = { Role: number; InvitedRole: number; LastChangedByAccountId: number | null } + const storedRoleOf = async (roomId: number, accountId: number) => + (await env.DB.prepare( + `SELECT role AS Role, invited_role AS InvitedRole, + last_changed_by_account_id AS LastChangedByAccountId + FROM room_role WHERE room_id = ?1 AND account_id = ?2` + ) + .bind(roomId, accountId) + .first()) ?? undefined + // A subroom as the client sees it. There is no GET for a single subroom — the client // reads them off the room — so tests do the same. const subRoomOf = async ( @@ -2091,7 +2157,9 @@ describe('rooms endpoints', () => { success: false, error: 'You must provide a valid role!', }) - expect(await roleOf(885)).toMatchObject({ InvitedRole: 30 }) + expect(await storedRoleOf(2, 885)).toMatchObject({ Role: 0, InvitedRole: 30 }) + // An entry that is only an invite is not a role the room serves. + expect(await roleOf(885)).toBeUndefined() }) it('a revoke pushes a RoomUpdate to the room and to the player who lost the role', async () => { @@ -2150,9 +2218,13 @@ describe('rooms endpoints', () => { ) // RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership, - // leaving a pending offer and no role yet. + // leaving a pending offer and no role yet. The offer is stored on their entry but + // the room as SERVED does not list them: they are not a member until they accept. expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200) - expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 }) + expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 }) + expect(await roleOf(850)).toBeUndefined() + const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Roles: Role[] } + expect(room2.Roles.some((r) => r.AccountId === 850)).toBe(false) // No token → 401 (auth gate). expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401) @@ -2165,7 +2237,7 @@ describe('rooms endpoints', () => { await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller)) ).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' }) } - expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 }) + expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 }) // Unknown room → failure envelope. expect( await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850')) @@ -2182,7 +2254,7 @@ describe('rooms endpoints', () => { expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({ success: false, }) - expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 }) + expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 }) // A player with no entry in the room at all is the same rejection — probing tells // them nothing. @@ -2229,11 +2301,13 @@ describe('rooms endpoints', () => { // Invited, then declined: the whole entry goes, leaving `Roles` as it was before the // offer rather than an entry with nothing pending on it. expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200) - expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 }) + expect(await storedRoleOf(2, 851)).toMatchObject({ Role: 0, InvitedRole: 30 }) + expect(await roleOf(851)).toBeUndefined() const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851') expect(declined.status).toBe(200) expect(await envOf(declined)).toMatchObject({ success: true, error: '' }) + expect(await storedRoleOf(2, 851)).toBeUndefined() expect(await roleOf(851)).toBeUndefined() // And the decline can't be replayed either — there is no entry left to answer. @@ -2248,6 +2322,7 @@ describe('rooms endpoints', () => { expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 }) expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200) expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200) + expect(await storedRoleOf(2, 852)).toBeUndefined() expect(await roleOf(852)).toBeUndefined() }) @@ -2495,12 +2570,14 @@ describe('rooms endpoints', () => { expect(await res.json()).toEqual({ success: true }) // A new entry, holding no role yet — only the pending offer, stamped with the inviter. - expect(await rolesOf(2)).toContainEqual({ - AccountId: 187, + // STORED, not served: the room's role list carries held roles only, so the invited + // player does not appear on it until they accept. + expect(await storedRoleOf(2, 187)).toEqual({ Role: 0, InvitedRole: 30, LastChangedByAccountId: 1, }) + expect((await rolesOf(2)).find((r) => r.AccountId === 187)).toBeUndefined() // One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited — // the entry alone is silent, this is what raises the invite on their client. @@ -7320,7 +7397,7 @@ it('omits hydrated Badges when persisting a room settings edit', async () => { // The badge lookup runs on every room page, served by a partial index over the badge rows // (api migrations/0035, mirrored in SCHEMA_DDL above). Only a PUBLISHED badge with a built save // hydrates; a draft stays on its creator's Studio shelf. -it('GET /rooms/:id hydrates the room\'s published badges', async () => { +it("GET /rooms/:id hydrates the room's published badges", async () => { await seedRoomWithSubRooms(env.DB, { RoomId: 987671, Name: 'BadgeHydration', diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index a1f04be..bff59c8 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -292,6 +292,47 @@ export function roomRoles(room: Room): RoomRole[] { })) } +/** + * Whether a `Roles` entry records a role the account actually HOLDS, as opposed to one it has + * only been offered. An invite is an entry with `Role` {@link Role.None} and the offer on + * `InvitedRole`; it stays that way until the player accepts ({@link answerRoomRoleInvite}) + * or declines (which drops the entry). Tolerates the raw blob shape — older rooms carry + * entries without a `Role` key, which read as None, and a malformed entry is not a role. + */ +function holdsRole(entry: unknown): boolean { + if (entry === null || typeof entry !== 'object') return false + return Number((entry as Partial).Role ?? Role.None) !== Role.None +} + +/** + * A room's HELD roles — {@link roomRoles} without the pending invites. What the client is + * shown as the room's role list: an invited player has no standing in the room until they + * accept, and listing them as a member with `Role` 0 showed them on the room's member list + * before they had agreed to anything. The offer reaches them as a `RoomCoOwnerInvited` + * message instead, and the entry itself stays in the stored room for + * {@link answerRoomRoleInvite} to promote. + */ +export function confirmedRoomRoles(room: Room): RoomRole[] { + return roomRoles(room).filter((r) => r.Role !== Role.None) +} + +/** + * The room as SERVED, with pending invites dropped from its `Roles` — a shallow copy; the + * room passed in is untouched, so a caller can keep writing from it. Entries are kept in the + * blob's own shape (not normalised through {@link roomRoles}), so a room that had no + * pending invite serializes exactly as before. A room with no `Roles` array is returned as + * is. + * + * Apply this at the response boundary ONLY, never to a room that is about to be written + * back. Rooms are stored as whole blobs and every writer rewrites the blob from the loaded + * room, so dropping the pending entries any earlier would silently erase every open invite + * on the room's next unrelated edit (a renamed room, a new load screen). + */ +export function withConfirmedRoles(room: T): T { + if (!Array.isArray(room.Roles)) return room + return { ...room, Roles: room.Roles.filter(holdsRole) } +} + /** * The statements that bring `room_role` in line with a room's `Roles`: drop the room's rows, * then insert each entry whole, as {@link roomRoles} reads it. Every entry is stored, @@ -3317,6 +3358,13 @@ export async function countRoomsByCreator(db: D1Database, accountId: number): Pr * room the player made. A room matching BOTH halves appears once — the roles half is an * EXISTS, not a join. * + * A PENDING invite is not a job. An invited account holds a `Roles` entry with `Role` 0 and + * the offer on `InvitedRole` until it answers, and that entry is indexed like any other — + * so without the `role <> 0` guard a room showed up in the player's "rooms you contribute + * to" (and, through `ownedby/me`, was considered for "rooms you own") the moment someone + * invited them, before they had accepted anything. Only a held role counts here; the offer + * reaches them as a `RoomCoOwnerInvited` message, not through this list. + * * Roles live inside the room blob, so the roles half reads `room_role`, the trigger-kept * index of them, rather than a `json_each` over every room's `$.Roles` (which scanned and * parsed the whole table on every call). Both halves are index lookups, and SQLite runs @@ -3328,9 +3376,9 @@ export async function getContributedRooms(db: D1Database, accountId: number): Pr .prepare( `SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1 - OR room_id IN (SELECT room_id FROM room_role WHERE account_id = ?1)` + OR room_id IN (SELECT room_id FROM room_role WHERE account_id = ?1 AND role <> ?2)` ) - .bind(accountId) + .bind(accountId, Role.None) .all() return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true) }