From 8364a0b5f6df645c663f91d49900cabd03afb1a1 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 12 Aug 2026 15:57:09 -0400 Subject: [PATCH] [event] fixup even listing, eliminate some misc log errors --- apps/api/src/events-db.ts | 72 +++++++++++++++++++-- apps/api/src/openapi.ts | 39 +++++++++++ apps/api/src/routes/events.ts | 55 ++++++++++++++++ apps/api/src/routes/gameplay.ts | 17 ++++- apps/api/src/test/integration/api.test.ts | 79 ++++++++++++++++++++++- apps/api/static/gameconfigs-v1-all.json | 8 +-- 6 files changed, 257 insertions(+), 13 deletions(-) diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index e521e66..38c5e3c 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -73,14 +73,49 @@ export function isEventResponseType(value: number): boolean { return EVENT_RESPONSE_VALUES.includes(value) } -/** One player's answer to one event. */ +/** + * One player's answer to one event. + * + * `id` is the row's SQLite `rowid` — the table has a composite primary key, so it's a + * rowid table and the implicit id is free. It's what the RSVP list serves as + * `PlayerEventResponseId`, and it's stable: a changed answer is an UPDATE through the + * composite key (same rowid), and nothing ever deletes an RSVP row. + */ export interface EventAttendeeRow { + id: number event_id: number player_id: number status: number responded_at: string } +/** + * One RSVP as `GET /api/playerevents/v1/:eventId/responses` serves it — the PascalCase + * projection of an `event_attendee` row. + * + * `CreatedAt` is the stored `responded_at`, so it's the time of the answer CURRENTLY + * recorded, not of the player's first one: changing your mind updates the row in place + * (one row per player per event), and the client shows the answer that stands. + */ +export interface PlayerEventResponse { + PlayerEventResponseId: number + PlayerEventId: number + PlayerId: number + CreatedAt: string + Type: number +} + +/** Project an RSVP row into the response the RSVP list serves. */ +export function toEventResponse(row: EventAttendeeRow): PlayerEventResponse { + return { + PlayerEventResponseId: row.id, + PlayerEventId: row.event_id, + PlayerId: row.player_id, + CreatedAt: row.responded_at, + Type: row.status, + } +} + /** * A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and * the settings the event runs under. Served verbatim by every read endpoint. @@ -170,6 +205,27 @@ export interface PlayerEventNotification { broadcastingRoomInstanceId: number | null } +/** + * The projection the browse feed (`GET /api/playerevents/v1`) serves. PascalCase like + * the stored record, but not identical to it — don't unify them: + * + * - it drops `State`, which the feed does not carry; + * - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing + * broadcasts an event yet, so it is always null). + * + * That's the shape observed on this endpoint; the by-id / bulk / search reads serve the + * stored record verbatim and keep `State`. + */ +export interface PlayerEventListing extends Omit { + BroadcastingRoomInstanceId: number | null +} + +/** Project a stored event into the browse feed's listing. */ +export function toEventListing(event: PlayerEvent): PlayerEventListing { + const { State: _State, ...rest } = event + return { ...rest, BroadcastingRoomInstanceId: null } +} + /** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */ function toTickPrecision(iso: string): string { const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso) @@ -438,18 +494,26 @@ export async function getEventResponse( playerId: number ): Promise { return db - .prepare('SELECT * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2') + .prepare('SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2') .bind(eventId, playerId) .first() } -/** Everyone who answered an event, in the order they responded. Backs a future guest list. */ +/** + * Everyone who answered an event, in the order they responded — the guest list behind + * `GET /api/playerevents/v1/:eventId/responses`. Ties on the timestamp (the creator's + * own Going row shares its second with a fast first RSVP) break on the player id, so + * the order is stable. + */ export async function getEventAttendees( db: D1Database, eventId: number ): Promise { const { results } = await db - .prepare('SELECT * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id') + .prepare( + `SELECT rowid AS id, * FROM event_attendee + WHERE event_id = ?1 ORDER BY responded_at, player_id` + ) .bind(eventId) .all() return results diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 5f1e784..567b5bb 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -435,6 +435,15 @@ export const KeepsakeConfig = z.object({ SocialXpBoostEnabled: z.boolean(), }) +/** + * `GET /api/keepsakes/categories` — the keepsake catalog, as a counted result set + * rather than the bare list the stubs around it serve. Empty until a catalog exists. + */ +export const KeepsakeCategories = z.object({ + Results: JsonArray.describe('The categories — empty, as no keepsake catalog is stored'), + TotalResults: z.int().describe('How many results `Results` carries'), +}) + /** * A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint * serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and @@ -460,6 +469,19 @@ export const PlayerEventDto = z.object({ CanRequestBroadcastPermissions: z.int(), }) +/** + * `GET /api/playerevents/v1` — the browse feed's listing. The same record minus + * `State`, plus a `BroadcastingRoomInstanceId` (always null — nothing broadcasts an + * event yet). That's the shape observed on this endpoint; the other reads serve the + * stored record verbatim, so don't unify the two. + */ +export const PlayerEventListingDto = PlayerEventDto.omit({ State: true }).extend({ + BroadcastingRoomInstanceId: z + .int() + .nullable() + .describe('Always null — no event broadcasts to a room instance yet'), +}) + /** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */ export const PlayerEventResultDto = z.object({ Result: z.int().describe('0 = success'), @@ -484,6 +506,23 @@ export const PlayerEventRequest = PlayerEventDto.partial().extend({ .describe('The event’s fields, if nested rather than posted at the top level'), }) +/** + * `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as + * the guest list serves it. + */ +export const PlayerEventResponseDto = z.object({ + PlayerEventResponseId: z.int().describe('Stable id of the RSVP row'), + PlayerEventId: z.int(), + PlayerId: z.int(), + CreatedAt: z + .string() + .describe( + 'When the answer that stands was given — a changed answer updates the row, so this ' + + 'moves with it rather than recording the player’s first response' + ), + Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'), +}) + /** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */ export const PlayerEventRespondRequest = z.object({ PlayerEventId: z.int(), diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 9f11bb5..fa5859f 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -8,6 +8,7 @@ import { logger } from '@repo/hono-helpers' import { NotificationType } from '../../../notify/src/notification-types' import { createEvent, + getEventAttendees, getEventById, getEventsByClubs, getEventsByCreator, @@ -18,6 +19,8 @@ import { parseEventBody, searchEvents, setEventResponse, + toEventListing, + toEventResponse, toEventNotification, toEventResult, updateEvent, @@ -31,8 +34,10 @@ import { jsonBody, pageParams, PlayerEventDto, + PlayerEventListingDto, PlayerEventRequest, PlayerEventRespondRequest, + PlayerEventResponseDto, PlayerEventResultDto, PlayerEventsAll, PlayerEventsPage, @@ -83,6 +88,33 @@ async function notifyEventCreated(c: Context, event: PlayerEvent): Promise< * if they're unified. */ export const eventRoutes = new Hono({ strict: false }) + // The player-events browse feed — everything upcoming or running, soonest first. Same + // query `/search` runs with no text, but its own projection: this feed drops `State` + // and carries a `BroadcastingRoomInstanceId`, so it goes through `toEventListing`. + .get( + '/api/playerevents/v1', + describeRoute({ + tags: ['Events'], + summary: 'The player-events browse feed', + description: + 'The default feed on the player-events screen: every event that has not finished ' + + 'yet — upcoming and running — soonest first, paginated via skip/take. A bare ' + + 'array.\n\n' + + 'Each entry is the browse LISTING, not the stored record the by-id, bulk and ' + + 'search reads serve: it drops `State` and carries ' + + '`BroadcastingRoomInstanceId` (always null — nothing broadcasts an event yet). ' + + 'That is the shape observed on this endpoint; keep the two projections apart.', + parameters: pageParams(50), + responses: { 200: json(PlayerEventListingDto.array(), 'The events that have not ended') }, + }), + async (c) => { + const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50 + const events = await searchEvents(c.env.DB, '', skip, take) + return c.json(events.map(toEventListing)) + } + ) + .get( '/api/playerevents/v1/all', describeRoute({ @@ -390,6 +422,29 @@ export const eventRoutes = new Hono({ strict: false }) } ) + // An event's guest list — every RSVP row, whatever the answer. + .get( + '/api/playerevents/v1/:eventId{[0-9]+}/responses', + describeRoute({ + tags: ['Events'], + summary: 'An event’s RSVPs', + description: + 'Every answer given to an event, in the order they were given — declines and ' + + 'maybes included, not just the Going rows `AttendeeCount` counts. One entry per ' + + 'player: a player who changed their mind has one row carrying the answer that ' + + 'stands, and `CreatedAt` moves with it.\n\n' + + 'A bare array, and an unknown event is an empty one rather than a 404 — like the ' + + 'other list reads here. An event always has at least its creator’s Going row.', + parameters: [idParam('eventId', 'Event id')], + responses: { 200: json(PlayerEventResponseDto.array(), 'The event’s RSVPs') }, + }), + async (c) => { + const eventId = Number.parseInt(c.req.param('eventId'), 10) + const attendees = await getEventAttendees(c.env.DB, eventId) + return c.json(attendees.map(toEventResponse)) + } + ) + // A single event. Registered last so the literal `/bulk` and `/search` paths above // are matched first; the `[0-9]+` constraint keeps them apart regardless. .get( diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index 53ea870..f6eafff 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -11,6 +11,7 @@ import { JsonArray, jsonBody, JsonObject, + KeepsakeCategories, KeepsakeConfig, SanitizeRequest, stringParam, @@ -97,15 +98,25 @@ export const gameplayRoutes = new Hono({ strict: false }) }), (c) => c.body(null, 204) ) + // A counted result set, NOT the bare list the stubs around it serve: the client parses + // this one as an object and an array fails it outright — "expected:'{', actual:'[', at + // offset:0", logged as "Failed to get keepsake categories" — which takes the keepsake + // load down with it. `TotalResults` is the length of `Results`, not a total behind a + // page; the reference returns `results.Length`. .get( '/api/keepsakes/categories', describeRoute({ tags: ['Gameplay'], summary: 'Keepsake categories', - description: 'No keepsake catalog yet, so this is an empty list.', - responses: { 200: json(JsonArray, 'An empty list') }, + description: + 'No keepsake catalog yet, so the result set is empty — but it IS a result set ' + + '(`{ Results, TotalResults }`), not the empty list the stubs around it serve. ' + + 'The client parses this one as an object and fails on an array ("expected \'{\', ' + + 'actual \'[\'"), taking the keepsake load down with it. `TotalResults` counts ' + + '`Results` itself — there is no paging here.', + responses: { 200: json(KeepsakeCategories, 'An empty result set') }, }), - (c) => c.json([]) + (c) => c.json({ Results: [], TotalResults: 0 }) ) // ---- Objectives / events / rewards --------------------------------------- diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 098538e..5ad7bae 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -457,12 +457,14 @@ describe('public endpoints', () => { expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true }) }) - test('GET /api/keepsakes/rooms/:id returns 204; categories returns []', async () => { + test('GET /api/keepsakes/rooms/:id returns 204; categories returns an empty result set', async () => { const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`) expect(room.status).toBe(204) + // A result set, not a list: the client parses this one as an object and an array + // fails it outright ("expected '{', actual '['"). const cats = await exports.default.fetch(`${ORIGIN}/api/keepsakes/categories`) expect(cats.status).toBe(200) - expect(await cats.json()).toEqual([]) + expect(await cats.json()).toEqual({ Results: [], TotalResults: 0 }) }) test('GET /voice/config returns an object', async () => { @@ -3056,6 +3058,30 @@ describe('player events', () => { expect(await search('?skip=1&take=1')).toEqual([all[1]]) }) + test('GET /api/playerevents/v1 serves the browse feed as listings', async () => { + const res = await get('/api/playerevents/v1') + expect(res.status).toBe(200) + const feed = (await res.json()) as Array + + // Upcoming and live, soonest first; what has already ended is left out. + const ids = feed.map((e) => e.PlayerEventId) + expect(ids).toContain(upcoming.PlayerEventId) + expect(ids).toContain(liveEvent.PlayerEventId) + expect(ids).not.toContain(pastEvent.PlayerEventId) + const starts = feed.map((e) => e.StartTime) + expect([...starts].sort()).toEqual(starts) + + // The listing projection — no `State`, and a null broadcasting instance — not the + // stored record the by-id read serves. + const entry = feed.find((e) => e.PlayerEventId === upcoming.PlayerEventId)! + expect(entry).toEqual({ ...upcoming, State: undefined, BroadcastingRoomInstanceId: null }) + expect(Object.hasOwn(entry, 'State')).toBe(false) + + // Paged like the other feeds. + expect(await (await get('/api/playerevents/v1?take=1')).json()).toEqual([feed[0]]) + expect(await (await get('/api/playerevents/v1?skip=1&take=1')).json()).toEqual([feed[1]]) + }) + test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => { const res = await get('/api/playerevents/v1/searchlive') expect(res.status).toBe(200) @@ -3149,6 +3175,53 @@ describe('player events', () => { expect(fetched.AttendeeCount).toBe(1) }) + test('GET /api/playerevents/v1/:eventId/responses lists every RSVP, one per player', async () => { + const event = await create({ RoomId: 3, Name: 'Guest List', StartTime: at(HOUR) }) + const id = event.PlayerEventId + const responses = async (): Promise< + Array<{ + PlayerEventResponseId: number + PlayerEventId: number + PlayerId: number + CreatedAt: string + Type: number + }> + > => (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as never + + // The creator's own Going row, from create. + const initial = await responses() + expect(initial).toEqual([ + { + PlayerEventResponseId: expect.any(Number), + PlayerEventId: id, + PlayerId: 42, + CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/), + Type: 0, + }, + ]) + + // Declines and maybes are listed too — not just what AttendeeCount counts. + await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '43') + const withDecline = await responses() + expect(withDecline.map((r) => [r.PlayerId, r.Type])).toEqual([ + [42, 0], + [43, 2], + ]) + + // Changing an answer updates the row in place: same id, new Type — never a second + // entry for the player. + await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43') + const changed = await responses() + expect(changed).toHaveLength(2) + expect(changed[1]!.PlayerEventResponseId).toBe(withDecline[1]!.PlayerEventResponseId) + expect(changed[1]!.Type).toBe(1) + + // An unknown event is an empty list, not a 404 — like the other list reads. + const unknown = await get('/api/playerevents/v1/999999/responses') + expect(unknown.status).toBe(200) + expect(await unknown.json()).toEqual([]) + }) + test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => { const event = await create({ RoomId: 3, Name: 'Guarded' }) @@ -3304,6 +3377,7 @@ describe('openapi', () => { 'GET /api/messages/v2/get', 'GET /api/playerReputation/v1/{id}', 'GET /api/playerReputation/v2/bulk', + 'GET /api/playerevents/v1', 'GET /api/playerevents/v1/all', 'GET /api/playerevents/v1/bulk', 'GET /api/playerevents/v1/club/{clubId}', @@ -3312,6 +3386,7 @@ describe('openapi', () => { 'GET /api/playerevents/v1/searchlive', 'GET /api/playerevents/v1/tagfilters', 'GET /api/playerevents/v1/{eventId}', + 'GET /api/playerevents/v1/{eventId}/responses', 'GET /api/players/v1/progression/{id}', 'GET /api/players/v2/progression/bulk', 'GET /api/quickPlay/v1/getandclear', diff --git a/apps/api/static/gameconfigs-v1-all.json b/apps/api/static/gameconfigs-v1-all.json index 27b07aa..eee5969 100644 --- a/apps/api/static/gameconfigs-v1-all.json +++ b/apps/api/static/gameconfigs-v1-all.json @@ -33,19 +33,19 @@ "EndTime": null, "Key": "AntiHile.DC", "StartTime": null, - "Value": "true" + "Value": "false" }, { "EndTime": null, "Key": "AntiHile.LPD", "StartTime": null, - "Value": "true" + "Value": "false" }, { "EndTime": null, "Key": "AntiHile.QD", "StartTime": null, - "Value": "true" + "Value": "false" }, { "EndTime": null, @@ -117,7 +117,7 @@ "EndTime": null, "Key": "Backtrace.stopTimeUTC", "StartTime": null, - "Value": "9999-09-28 23:55" + "Value": "2026-06-01 00:00" }, { "EndTime": null,