From 65611c15d882379fd6c6507b6c469580f592c5e4 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 17:50:21 -0400 Subject: [PATCH] fix custom loading screens, add private endpoint for match into instance --- CLAUDE.md | 5 ++++ apps/rooms/src/openapi.ts | 2 +- apps/rooms/src/rooms.app.ts | 31 +++++++++++++-------- apps/rooms/src/test/integration/api.test.ts | 25 ++++++++--------- packages/domain/src/room-instance-db.ts | 29 +++++++++++++++++++ 5 files changed, 65 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac28ab5..334318c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,11 @@ inconsistency here without checking the client first. - A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not image names — a bare string array fails the client's parser ("expected '{'"). The list is packed: removing an image shifts the rest up, never leaving a blank slot. +- A room's `LoadScreens` (`rooms`: `PUT /rooms/:id/loadscreen`) is an array — the + client's parser wants one — but the client renders only the FIRST entry and only ever + posts one. So the endpoint REPLACES the list rather than appending: an appended screen + sits unreachable behind the old one and setting a load screen looks like it did + nothing. Keep the array shape for eventual multi-screen support. - Endpoints the client re-renders from must return the updated entity, not `{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old clubhouse on screen until it answered the full details envelope. diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index ce772aa..5bbcf79 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -505,7 +505,7 @@ export const RestrictionsRequest = z.object({ supportsJuniors: z.string().optional().describe('`True` / `False`'), }) -/** `PUT /rooms/{roomId}/loadscreen` — appends one screen to the list. */ +/** `PUT /rooms/{roomId}/loadscreen` — the posted screen replaces the whole list. */ export const LoadScreenRequest = z.object({ imageName: z.string().describe('A key from the storage upload'), title: z.string().optional(), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index b11750f..2b47cc0 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -1752,24 +1752,31 @@ const app = new Hono() } ) - // Add a load screen to a room (`LoadScreens[]` — the images shown while the room - // loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName` - // form field plus optional `title`/`subtitle`. Appends one - // `{ ImageName, Title, Subtitle }` to the existing list and returns the updated - // room in the `{ success, error, value }` envelope. + // Set a room's load screen (`LoadScreens[]` — the image shown while the room loads). + // Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName` form field + // plus optional `title`/`subtitle`. REPLACES the list with the single posted + // `{ ImageName, Title, Subtitle }` and returns the updated room in the + // `{ success, error, value }` envelope. + // + // The field is an array because the client's parser wants one, but the client only + // ever renders (and only ever posts) a single screen — appending left the old screen + // in slot 0 and the new one unreachable behind it, so setting a load screen appeared + // to do nothing. Kept as an array so multi-screen support can land without a + // migration. .put( '/rooms/:roomId{[0-9]+}/loadscreen', describeRoute({ tags: ['Room settings'], - summary: 'Add a load screen to a room', + summary: 'Set a room’s load screen', description: [ - 'APPENDS one `{ ImageName, Title, Subtitle }` to the room’s `LoadScreens` — the images', - 'shown while the room loads. There is no remove or replace counterpart. Owner or', - 'co-owner only (403 otherwise).', + 'REPLACES the room’s `LoadScreens` with the single posted `{ ImageName, Title,', + 'Subtitle }` — the image shown while the room loads. The field is an array (the', + 'client’s parser expects one) but the client only supports a single screen, so this', + 'never appends. Owner or co-owner only (403 otherwise).', ].join(' '), security: AUTHED, parameters: [roomIdParam], - requestBody: form(LoadScreenRequest, 'The load screen to append'), + requestBody: form(LoadScreenRequest, 'The load screen to set'), responses: { 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), 401: UNAUTHORIZED_RESPONSE, @@ -1793,8 +1800,8 @@ const app = new Hono() const title = typeof body.title === 'string' ? body.title : '' const subtitle = typeof body.subtitle === 'string' ? body.subtitle : '' - const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : [] - const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }] + // The posted screen becomes the whole list — the client shows one load screen. + const loadScreens = [{ ImageName: imageName, Title: title, Subtitle: subtitle }] const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens }) await pushRoomUpdate(c, accountId, updated) return roomEnvelope(c, updated) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 4228876..eb66bc4 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1258,7 +1258,7 @@ describe('rooms endpoints', () => { expect(typeof room.SupportsMobile).toBe('boolean') }) - it('PUT /rooms/:id/loadscreen appends a load screen (auth-gated, owner/co-owner-only)', async () => { + it('PUT /rooms/:id/loadscreen replaces the load screen (auth-gated, owner/co-owner-only)', async () => { const screensOf = async (): Promise>> => { const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { LoadScreens?: Array> @@ -1278,10 +1278,8 @@ describe('rooms endpoints', () => { success: false, }) - const before = (await screensOf()).length - - // Owner adds one (imageName + title + subtitle) — appended, and the success - // envelope carries the updated room. + // Owner sets one (imageName + title + subtitle) — the success envelope carries the + // updated room, and the posted screen is the ONLY entry. const added = await envOf( await putForm( '/rooms/2/loadscreen', @@ -1290,18 +1288,17 @@ describe('rooms endpoints', () => { ) ) expect(added).toMatchObject({ success: true }) - expect(added.value?.LoadScreens as unknown[]).toContainEqual({ - ImageName: 'sharecamera/2026-07-15/abc.jpg', - Title: 'asdf', - Subtitle: 'sdf', - }) - expect(await screensOf()).toHaveLength(before + 1) + expect(added.value?.LoadScreens).toEqual([ + { ImageName: 'sharecamera/2026-07-15/abc.jpg', Title: 'asdf', Subtitle: 'sdf' }, + ]) + expect(await screensOf()).toHaveLength(1) - // A second call appends rather than replacing; title/subtitle default to empty. + // A second call REPLACES rather than appending (the client renders one screen, so + // an appended one would sit unreachable behind the old); title/subtitle default to + // empty when omitted. const co = await envOf(await putForm('/rooms/2/loadscreen', { imageName: 'second.jpg' }, '2')) expect(co).toMatchObject({ success: true }) - expect(await screensOf()).toHaveLength(before + 2) - expect(await screensOf()).toContainEqual({ ImageName: 'second.jpg', Title: '', Subtitle: '' }) + expect(await screensOf()).toEqual([{ ImageName: 'second.jpg', Title: '', Subtitle: '' }]) }) it('PUT /rooms/:id/accessibility sets the room-level Accessibility (auth-gated, owner/co-owner-only)', async () => { diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index e002923..da087c4 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -222,6 +222,35 @@ export async function setRoomInstanceInProgress( return toDto(stored) } +/** + * Flip an instance's `isPrivate` flag, rewriting the JSON blob (the generated + * `is_private` column follows it). Returns the updated DTO, or null when the + * instance doesn't exist. + * + * Marking an instance private is what closes it to strangers: {@link + * getJoinableInstance} only ever reuses instances with `is_private = 0`, so a public + * matchmake stops landing new players here the moment this is set. Everyone already + * inside stays — this shuts the door, it doesn't clear the room. + */ +export async function setRoomInstancePrivate( + db: D1Database, + id: number, + isPrivate: boolean +): Promise { + const row = await db + .prepare('SELECT data FROM room_instance WHERE id = ?1') + .bind(id) + .first<{ data: string }>() + if (!row) return null + const stored = parse(row.data) + stored.isPrivate = isPrivate + await db + .prepare('UPDATE room_instance SET data = ?1 WHERE id = ?2') + .bind(JSON.stringify(stored), id) + .run() + return toDto(stored) +} + /** * Recompute an instance's `isFull` flag from live match presence: full once the * number of players currently present in the instance reaches its `maxCapacity`