[event] fixup even listing, eliminate some misc log errors

This commit is contained in:
Devin Zuczek
2026-08-12 15:57:09 -04:00
parent d3838fb590
commit 8364a0b5f6
6 changed files with 257 additions and 13 deletions
+68 -4
View File
@@ -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<PlayerEvent, 'State'> {
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<EventAttendeeRow | null> {
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<EventAttendeeRow>()
}
/** 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<EventAttendeeRow[]> {
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<EventAttendeeRow>()
return results
+39
View File
@@ -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 events 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 players first response'
),
Type: z.int().describe('0 Going, 1 Interested, 2 Cant go'),
})
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
export const PlayerEventRespondRequest = z.object({
PlayerEventId: z.int(),
+55
View File
@@ -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<App>, event: PlayerEvent): Promise<
* if they're unified.
*/
export const eventRoutes = new Hono<App>({ 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<App>({ 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 events 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 creators Going row.',
parameters: [idParam('eventId', 'Event id')],
responses: { 200: json(PlayerEventResponseDto.array(), 'The events 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(
+14 -3
View File
@@ -11,6 +11,7 @@ import {
JsonArray,
jsonBody,
JsonObject,
KeepsakeCategories,
KeepsakeConfig,
SanitizeRequest,
stringParam,
@@ -97,15 +98,25 @@ export const gameplayRoutes = new Hono<App>({ 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 ---------------------------------------
+77 -2
View File
@@ -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<PlayerEvent & { BroadcastingRoomInstanceId: null }>
// 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',
+4 -4
View File
@@ -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,