[rooms] keep invited roles on the roles endpoint for the permissions list

This commit is contained in:
Devin Zuczek
2026-10-26 18:25:06 -04:00
parent f2c16f198a
commit 207138ef97
3 changed files with 31 additions and 35 deletions
+8 -10
View File
@@ -14,7 +14,6 @@ import {
canManageRoomById, canManageRoomById,
cloneRoom, cloneRoom,
cloneSubRoom, cloneSubRoom,
confirmedRoomRoles,
countRoomsByCreator, countRoomsByCreator,
createNotification, createNotification,
createStudioRoom, createStudioRoom,
@@ -2659,15 +2658,14 @@ const app = new Hono<App>()
summary: 'A room’s roles', summary: 'A room’s roles',
description: [ description: [
'Everyone with a role in the room — its creator plus whoever has been granted Host,', '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', 'Moderator or CoOwner — as whole `RoomRole` records, PLUS every pending invite. It is',
'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the', 'public exactly like the room is: a role list says who runs a room, which the room',
'room is: a role list says who runs a room, which the room page already shows.', 'page already shows.',
'', '',
'Held roles only. A player with a standing co-owner invite they have not answered', 'Unlike the room DTO’s `Roles`, this list INCLUDES unanswered co-owner invites (`Role`',
'(`Role` 0, the offer on `InvitedRole`) is not listed here or on the room DTO: they', '0, the offer on `InvitedRole`): the client reads it to show the players with an',
'have no standing in the room until they accept, and the offer reaches them as a', 'invite pending. The room DTO leaves those entries out, because there an invited',
'`RoomCoOwnerInvited` message. A member who holds a role AND has a higher one pending', 'player read as a member before they had accepted anything.',
'is listed at the role they hold, `InvitedRole` included.',
'', '',
'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An', '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', '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<App>()
async (c) => { async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10) const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId) const room = await getRoomById(c.env.DB, roomId)
return c.json(room ? confirmedRoomRoles(room) : []) return c.json(room ? roomRoles(room) : [])
} }
) )
+23 -13
View File
@@ -576,8 +576,9 @@ describe('rooms endpoints', () => {
expect(ownedIds).toContain(30401) expect(ownedIds).toContain(30401)
expect(ownedIds).not.toContain(30408) expect(ownedIds).not.toContain(30408)
// Nor is the invited player part of the room anyone is shown: the room DTO and the // Nor is the invited player part of the room as served: the room DTO carries only HELD
// role list carry only HELD roles. The stored room keeps the entry for the accept. // 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 }> } type Served = { Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> }
const servedRoles = async () => const servedRoles = async () =>
((await (await SELF.fetch(`${ORIGIN}/rooms/30408`)).json()) as Served).Roles ((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([ expect(await (await SELF.fetch(`${ORIGIN}/rooms/30408/roles`)).json()).toEqual([
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }, { AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
]) ])
expect( expect(
await env.DB.prepare( await env.DB.prepare(
@@ -1729,8 +1731,7 @@ describe('rooms endpoints', () => {
const envOf = async (res: Response) => (await res.json()) as RoomEnv const envOf = async (res: Response) => (await res.json()) as RoomEnv
// A role entry as STORED, read off the `room_role` index — pending invites included. // 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 // The served room carries only held roles; `GET /rooms/:id/roles` lists invites too.
// nothing but an unanswered invite (`Role` 0) can only be observed here.
type StoredRole = { Role: number; InvitedRole: number; LastChangedByAccountId: number | null } type StoredRole = { Role: number; InvitedRole: number; LastChangedByAccountId: number | null }
const storedRoleOf = async (roomId: number, accountId: number) => const storedRoleOf = async (roomId: number, accountId: number) =>
(await env.DB.prepare( (await env.DB.prepare(
@@ -2158,8 +2159,8 @@ describe('rooms endpoints', () => {
error: 'You must provide a valid role!', error: 'You must provide a valid role!',
}) })
expect(await storedRoleOf(2, 885)).toMatchObject({ Role: 0, 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. // The role list still shows the invite as pending.
expect(await roleOf(885)).toBeUndefined() 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 () => { 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, // 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 // leaving a pending offer and no role yet. The role list shows the pending invite, but
// the room as SERVED does not list them: they are not a member until they accept. // 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 putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 }) 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[] } const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Roles: Role[] }
expect(room2.Roles.some((r) => r.AccountId === 850)).toBe(false) 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. // 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 putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
expect(await storedRoleOf(2, 851)).toMatchObject({ Role: 0, InvitedRole: 30 }) 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') const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
expect(declined.status).toBe(200) expect(declined.status).toBe(200)
@@ -2570,14 +2571,23 @@ describe('rooms endpoints', () => {
expect(await res.json()).toEqual({ success: true }) expect(await res.json()).toEqual({ success: true })
// A new entry, holding no role yet — only the pending offer, stamped with the inviter. // 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 // The role list shows it as a pending invite (the client lists pending invites from
// player does not appear on it until they accept. // it); the room DTO's `Roles` does not, until they accept.
expect(await storedRoleOf(2, 187)).toEqual({ expect(await storedRoleOf(2, 187)).toEqual({
Role: 0, Role: 0,
InvitedRole: 30, InvitedRole: 30,
LastChangedByAccountId: 1, 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 — // 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. // the entry alone is silent, this is what raises the invite on their client.
-12
View File
@@ -304,18 +304,6 @@ function holdsRole(entry: unknown): boolean {
return Number((entry as Partial<RoomRole>).Role ?? Role.None) !== Role.None return Number((entry as Partial<RoomRole>).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 * 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 * room passed in is untouched, so a caller can keep writing from it. Entries are kept in the