From 207138ef979130471effc7c32ca183f7ea331c26 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 26 Oct 2026 18:25:06 -0400 Subject: [PATCH] [rooms] keep invited roles on the roles endpoint for the permissions list --- apps/rooms/src/rooms.app.ts | 18 +++++------ apps/rooms/src/test/integration/api.test.ts | 36 +++++++++++++-------- packages/domain/src/rooms-db.ts | 12 ------- 3 files changed, 31 insertions(+), 35 deletions(-) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index fae9de2..6ca4188 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -14,7 +14,6 @@ import { canManageRoomById, cloneRoom, cloneSubRoom, - confirmedRoomRoles, countRoomsByCreator, createNotification, createStudioRoom, @@ -2659,15 +2658,14 @@ const app = new Hono() summary: 'A room’s roles', description: [ 'Everyone with a role in the room — its creator plus whoever has been granted Host,', - 'Moderator or CoOwner — as whole `RoomRole` records. This is the same array the', - '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.', + 'Moderator or CoOwner — as whole `RoomRole` records, PLUS every pending invite. 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.', + 'Unlike the room DTO’s `Roles`, this list INCLUDES unanswered co-owner invites (`Role`', + '0, the offer on `InvitedRole`): the client reads it to show the players with an', + 'invite pending. The room DTO leaves those entries out, because there an invited', + 'player read as a member before they had accepted anything.', '', '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', @@ -2681,7 +2679,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 ? confirmedRoomRoles(room) : []) + return c.json(room ? roomRoles(room) : []) } ) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index aa60f2a..62140b1 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -576,8 +576,9 @@ describe('rooms endpoints', () => { 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. + // Nor is the invited player part of the room as served: the room DTO carries only HELD + // roles. `GET /rooms/:id/roles` DOES list the invite — the client shows pending invites + // from it — and 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 @@ -586,6 +587,7 @@ describe('rooms endpoints', () => { ]) expect(await (await SELF.fetch(`${ORIGIN}/rooms/30408/roles`)).json()).toEqual([ { AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }, + { AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 }, ]) expect( await env.DB.prepare( @@ -1729,8 +1731,7 @@ describe('rooms endpoints', () => { 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. + // The served room carries only held roles; `GET /rooms/:id/roles` lists invites too. type StoredRole = { Role: number; InvitedRole: number; LastChangedByAccountId: number | null } const storedRoleOf = async (roomId: number, accountId: number) => (await env.DB.prepare( @@ -2158,8 +2159,8 @@ describe('rooms endpoints', () => { error: 'You must provide a valid role!', }) 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() + // The role list still shows the invite as pending. + expect(await roleOf(885)).toMatchObject({ Role: 0, InvitedRole: 30 }) }) it('a revoke pushes a RoomUpdate to the room and to the player who lost the role', async () => { @@ -2218,11 +2219,11 @@ 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. The offer is stored on their entry but - // the room as SERVED does not list them: they are not a member until they accept. + // leaving a pending offer and no role yet. The role list shows the pending invite, but + // the room DTO 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 storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 }) - expect(await roleOf(850)).toBeUndefined() + expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 }) const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Roles: Role[] } expect(room2.Roles.some((r) => r.AccountId === 850)).toBe(false) @@ -2302,7 +2303,7 @@ describe('rooms endpoints', () => { // 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 storedRoleOf(2, 851)).toMatchObject({ Role: 0, InvitedRole: 30 }) - expect(await roleOf(851)).toBeUndefined() + expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 }) const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851') expect(declined.status).toBe(200) @@ -2570,14 +2571,23 @@ 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. - // STORED, not served: the room's role list carries held roles only, so the invited - // player does not appear on it until they accept. + // The role list shows it as a pending invite (the client lists pending invites from + // it); the room DTO's `Roles` does not, 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() + expect(await rolesOf(2)).toContainEqual({ + AccountId: 187, + Role: 0, + InvitedRole: 30, + LastChangedByAccountId: 1, + }) + const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { + Roles: Array<{ AccountId: number }> + } + expect(room2.Roles.some((r) => r.AccountId === 187)).toBe(false) // 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. diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index bff59c8..ad05dd2 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -304,18 +304,6 @@ function holdsRole(entry: unknown): boolean { 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