[api] another events endpoint

This commit is contained in:
Devin Zuczek
2026-08-24 18:17:02 -04:00
parent 0f281db32e
commit b355b6cea9
4 changed files with 106 additions and 0 deletions
@@ -0,0 +1,8 @@
-- The room_id index behind a room's event shelf (`GET /api/playerevents/v1/room/{roomId}`).
-- Owned by the `api` worker; generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- The column itself has been on the `event` table since 0006; only the index is new. The
-- club feed has had one since that migration and the room feed now reads the same way, so
-- without this a room's shelf scans every event in the database.
CREATE INDEX IF NOT EXISTS idx_event_room ON event (room_id);
+25
View File
@@ -41,6 +41,7 @@ export const SCHEMA_DDL: string[] = [
`CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`,
`CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_room ON event (room_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
`CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
@@ -861,6 +862,30 @@ export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promi
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* A room's events — what is happening in this room and what is coming up, soonest first.
* Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`).
*
* FINISHED events are left out, like the browse feed's: this answers "what can I still turn
* up to in this room", and an event that ended last month is not that. Running events count
* as current — the filter is on the END time, so an event stays listed until it is over
* rather than disappearing the moment it starts.
*
* Selected on the indexed room_id column, with the time bound in SQL too: end_time is a
* generated column of an ISO-8601 UTC string, so it compares lexicographically.
*/
export async function getEventsByRoom(
db: D1Database,
roomId: number,
now = Date.now()
): Promise<PlayerEvent[]> {
const { results } = await db
.prepare('SELECT data FROM event WHERE room_id = ?1 AND end_time >= ?2')
.bind(roomId, eventTime(now))
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* The events happening right now — started and not yet finished. Backs the "happening
* now" browse query. Both bounds compare lexicographically on the generated ISO-8601
+25
View File
@@ -17,6 +17,7 @@ import {
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getEventsByRoom,
getEventTags,
getLiveEvents,
inviteToEvent,
@@ -358,6 +359,30 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// A room's event shelf (`/room/12`) — what is on in this room, current and upcoming.
// A bare array of the stored record, like the multi-club shelf and `/searchlive`: the
// single-club form's `{ ContinuationToken, Events }` envelope is the odd one out, and a
// room's shelf is small enough that there is nothing to page.
.get(
'/api/playerevents/v1/room/:roomId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Player events in one room',
description:
'The events scheduled in a room — the shelf on the rooms page — soonest first. A ' +
'bare array of the stored record, the same projection `/searchlive` and the ' +
'multi-club shelf serve.\n\n' +
'CURRENT and UPCOMING only: the filter is on the END time, so a running event stays ' +
'listed until it is over rather than vanishing the moment it starts, and an event ' +
'that has finished is dropped — this answers what someone can still turn up to. A ' +
'room with nothing scheduled, and a room id that does not exist, both answer an ' +
'empty array; the shelf is about events, not about whether the room is real.',
parameters: [idParam('roomId', 'Room id')],
responses: { 200: json(PlayerEventDto.array(), 'The rooms current and upcoming events') },
}),
async (c) => c.json(await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
)
// Live player-event search (the "happening now" browse query) — events that have
// started and not yet finished. A bare array, like the multi-club feed.
.get(
+48
View File
@@ -4075,6 +4075,53 @@ describe('player events', () => {
expect(ids).not.toContain(pastEvent.PlayerEventId)
})
test('GET /api/playerevents/v1/room/:roomId serves that rooms current and upcoming events', async () => {
// A room of this test's own, so events other tests create can't drift into the shelf.
const soon = await create({
RoomId: 12,
Name: 'Room 12 Soon',
StartTime: at(2 * HOUR),
EndTime: at(3 * HOUR),
})
const running = await create({
RoomId: 12,
Name: 'Room 12 Running',
StartTime: at(-HOUR),
EndTime: at(HOUR),
})
const finished = await create({
RoomId: 12,
Name: 'Room 12 Finished',
StartTime: at(-3 * HOUR),
EndTime: at(-2 * HOUR),
})
const elsewhere = await create({
RoomId: 13,
Name: 'Room 13 Soon',
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
})
const res = await get('/api/playerevents/v1/room/12')
expect(res.status).toBe(200)
const events = (await res.json()) as PlayerEvent[]
// Soonest first, and RUNNING counts as current: the filter is on the end time, so an
// event stays on the shelf until it is over rather than vanishing when it starts.
expect(events.map((e) => e.PlayerEventId)).toEqual([running.PlayerEventId, soon.PlayerEventId])
// A finished event is dropped — the shelf answers what you can still turn up to — and
// another room's event is not this room's business.
expect(events.map((e) => e.PlayerEventId)).not.toContain(finished.PlayerEventId)
expect(events.map((e) => e.PlayerEventId)).not.toContain(elsewhere.PlayerEventId)
// A bare array of the STORED record, like `/searchlive` and the multi-club shelf —
// not the base projection the browse feed serves, and not the single-club envelope.
expect(events[0]).toEqual(asRecord(running, null))
// A room with nothing scheduled, and a room id nothing knows about, are both empty.
expect(await (await get('/api/playerevents/v1/room/999999')).json()).toEqual([])
})
test('GET /api/playerevents/v1/clubs is a bare array; /club/:id is a paged envelope', async () => {
// The client deserializes the multi-club form as a list — an envelope here fails
// with "expected:'[', actual:'{'". Do not unify the two.
@@ -4788,6 +4835,7 @@ describe('openapi', () => {
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
'GET /api/playerevents/v1/room/{roomId}',
'GET /api/playerevents/v1/search',
'GET /api/playerevents/v1/searchlive',
'GET /api/playerevents/v1/tagfilters',