diff --git a/apps/api/migrations/0012_event_room_index.sql b/apps/api/migrations/0012_event_room_index.sql new file mode 100644 index 0000000..92c86bc --- /dev/null +++ b/apps/api/migrations/0012_event_room_index.sql @@ -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); diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index 4c59329..ff6d30c 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -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 { + const { results } = await db + .prepare('SELECT data FROM event WHERE room_id = ?1 AND end_time >= ?2') + .bind(roomId, eventTime(now)) + .all() + 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 diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 30f02dd..f065991 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -17,6 +17,7 @@ import { getEventsByClubs, getEventsByCreator, getEventsByIds, + getEventsByRoom, getEventTags, getLiveEvents, inviteToEvent, @@ -358,6 +359,30 @@ export const eventRoutes = new Hono({ 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 room’s 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 room’s 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( diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index a06e115..98197a1 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4075,6 +4075,53 @@ describe('player events', () => { expect(ids).not.toContain(pastEvent.PlayerEventId) }) + test('GET /api/playerevents/v1/room/:roomId serves that room’s 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',