mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[event] fixup even listing, eliminate some misc log errors
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 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(
|
||||
|
||||
@@ -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 ---------------------------------------
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user