mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[rooms] only show confirmed role invites
This commit is contained in:
+84
-38
@@ -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<App>,
|
||||
playerId: number,
|
||||
room: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
async function pushRoomUpdate(c: Context<App>, playerId: number, room: Room): Promise<void> {
|
||||
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<App>,
|
||||
roomId: number,
|
||||
room: Record<string, unknown>,
|
||||
room: Room,
|
||||
alsoNotify: number[] = []
|
||||
): Promise<void> {
|
||||
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
|
||||
@@ -1114,16 +1114,44 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
|
||||
function roomEnvelope(c: Context<App>, 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 = <T extends { Results: Room[] }>(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<App>, 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<App>, 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<App>) {
|
||||
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<App>) {
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
'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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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<string, unknown> | 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<StoredRole>()) ?? 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',
|
||||
|
||||
@@ -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<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
|
||||
* 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<T extends Room>(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<RoomRow>()
|
||||
return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user