diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index 6ffaddb..c48e026 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -18,6 +18,7 @@ */ import { + Accessibility, glyphLength, MAX_EVENT_DESCRIPTION_LENGTH, MAX_EVENT_DURATION_MS, @@ -953,6 +954,72 @@ export async function getEventsByCreator( return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest) } +/** + * One entry of a profile's `Responses` — the event beside the player's RSVP to it. The + * event is the 17-key BASE shape (the client's v1 PlayerEvent), not the stored record. + */ +export interface PlayerEventWithResponse { + PlayerEvent: PlayerEventBase + PlayerEventResponse: PlayerEventResponse +} + +/** What anyone may see of a player's events — `GET /api/playerevents/v1/all/{playerId}`. */ +export interface PlayerPublicEvents { + Created: PlayerEventBase[] + Responses: PlayerEventWithResponse[] +} + +/** + * A player's PUBLIC events, as their profile shows them to anyone: the ones they created, + * and the ones they are attending — answered Going; a maybe, a decline and an unanswered + * invitation are nobody else's business — each soonest first. An event's creator holds a + * Going row for it, so their own events appear under both keys. + * + * Only `Accessibility` Public events are served, and FINISHED ones are left out, as the + * browse feed and the room shelf do: this is what the player is going to, not a history. + */ +export async function getPublicEventsByPlayer( + db: D1Database, + playerId: number, + now = Date.now() +): Promise { + const at = eventTime(now) + const [created, responses] = await db.batch>([ + db + .prepare( + `SELECT data FROM event + WHERE creator_player_id = ?1 AND end_time >= ?2 + AND json_extract(data, '$.Accessibility') = ?3` + ) + .bind(playerId, at, Accessibility.Public), + db + .prepare( + `SELECT e.data AS data, a.rowid AS id, a.event_id, a.player_id, a.status, a.responded_at + FROM event_attendee a + JOIN event e ON e.id = a.event_id + WHERE a.player_id = ?1 AND a.status = ?2 AND e.end_time >= ?3 + AND json_extract(e.data, '$.Accessibility') = ?4` + ) + .bind(playerId, EVENT_RESPONSE.going, at, Accessibility.Public), + ]) + return { + Created: created.results + .map((r) => JSON.parse(r.data) as PlayerEvent) + .sort(bySoonest) + .map(toEventBase), + Responses: responses.results + .map((r) => ({ + event: JSON.parse(r.data) as PlayerEvent, + response: toEventResponse(r as EventAttendeeRow), + })) + .sort((a, b) => bySoonest(a.event, b.event)) + .map(({ event, response }) => ({ + PlayerEvent: toEventBase(event), + PlayerEventResponse: response, + })), + } +} + /** * The events belonging to a set of clubs — the events shelf on a club's page, soonest * first. Selected on the indexed club_id column. An empty id list is an empty shelf diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index f01ab63..118336c 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1347,6 +1347,17 @@ export const PlayerEventsAll = z.object({ ), }) +/** + * `GET /api/playerevents/v1/all/{playerId}` — a player's public events. Both keys carry + * the 17-key base event; a `Responses` entry pairs it with the player's RSVP. + */ +export const PlayerPublicEvents = z.object({ + Created: z.array(PlayerEventBaseDto).describe('Public events the player created'), + Responses: z + .array(z.object({ PlayerEvent: PlayerEventBaseDto, PlayerEventResponse: PlayerEventResponseDto })) + .describe('Public events the player is attending, each beside their Going RSVP'), +}) + /** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */ export const PlayerEventsPage = z.object({ ContinuationToken: z.string().describe('Empty = no next page'), diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 0253fd1..fcbd112 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -23,6 +23,7 @@ import { getEventTags, getGoingPlayerIds, getLiveEvents, + getPublicEventsByPlayer, inviteToEvent, isEventResponseType, parseEventBody, @@ -64,6 +65,7 @@ import { PlayerEventsPage, PlayerEventTagsRequest, PlayerEventTimeRequest, + PlayerPublicEvents, stringQuery, SuccessErrorEnvelope, TagFilters, @@ -447,6 +449,31 @@ export const eventRoutes = new Hono({ strict: false }) } ) + // Another player's events, as their profile shows them: PUBLIC events only, and under + // `Responses` only the ones they are attending (Going). Public. Unlike the caller's own + // `/all` above, both keys carry the client's 17-key base event, and a `Responses` entry + // is a PAIR — the event beside the RSVP record. + .get( + '/api/playerevents/v1/all/:playerId{[0-9]+}', + describeRoute({ + tags: ['Events'], + summary: 'A player’s public events', + description: + 'The events on a player’s profile: the PUBLIC ones they created (`Created`) and ' + + 'the PUBLIC ones they are attending (`Responses`), each soonest first. Public (no ' + + 'auth); finished events are left out, and a player with none — or an unknown id — ' + + 'gets two empty lists.\n\n' + + 'Events are the client’s 17-key BASE event, as on the browse feed. A `Responses` ' + + 'entry is a pair, `{ PlayerEvent, PlayerEventResponse }`, and only Going RSVPs ' + + '(`Type` 0) are listed — a maybe, a decline or an unanswered invitation is not ' + + 'attending. A creator is Going to their own event, so it appears under both keys.', + parameters: [idParam('playerId', 'Player id')], + responses: { 200: json(PlayerPublicEvents, 'The player’s public events') }, + }), + async (c) => + c.json(await getPublicEventsByPlayer(c.env.DB, Number.parseInt(c.req.param('playerId'), 10))) + ) + // The tag filter chips on the player-events browse screen. Static: these are the // categories the client offers when creating an event, so the list doesn't depend on // what's stored. `TrendingFilters` is null even in the reference — it needs diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 0ed2917..24c7e36 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -8969,6 +8969,77 @@ describe('player events', () => { expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId]) }) + test('GET /api/playerevents/v1/all/:playerId serves a player’s PUBLIC events and Going RSVPs', async () => { + const respond = (id: number, type: number, sub: string): Promise => + post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub) + interface Profile { + Created: PlayerEvent[] + Responses: Array<{ + PlayerEvent: Record + PlayerEventResponse: Record + }> + } + const profile = async (playerId: number | string): Promise => { + // No token: the read is public. + const res = await get(`/api/playerevents/v1/all/${playerId}`) + expect(res.status).toBe(200) + return (await res.json()) as Profile + } + + // 610 hosts a public event, a private one and a finished one; 611 is Going to the + // first two, Interested in a third public one, and went to the finished one. + const open = await create({ RoomId: 3, Name: 'Profile Open', StartTime: at(2 * HOUR) }, '610') + const closed = await create( + { RoomId: 3, Name: 'Profile Closed', StartTime: at(3 * HOUR), Accessibility: 0 }, + '610' + ) + const maybe = await create({ RoomId: 3, Name: 'Profile Maybe', StartTime: at(HOUR) }, '610') + const over = await create( + { RoomId: 3, Name: 'Profile Over', StartTime: at(-3 * HOUR), EndTime: at(-2 * HOUR) }, + '610' + ) + await respond(open.PlayerEventId, 0, '611') + await respond(closed.PlayerEventId, 0, '611') + await respond(maybe.PlayerEventId, 1, '611') + await respond(over.PlayerEventId, 0, '611') + + // The attendee: exactly the public event they are Going to, as the pair. + const guest = await profile(611) + expect(guest.Created).toEqual([]) + expect(guest.Responses).toHaveLength(1) + const [entry] = guest.Responses + expect(Object.keys(entry).sort()).toEqual(['PlayerEvent', 'PlayerEventResponse']) + expect(entry.PlayerEventResponse).toEqual({ + PlayerEventResponseId: expect.any(Number), + PlayerEventId: open.PlayerEventId, + PlayerId: 611, + CreatedAt: expect.any(String), + Type: 0, + }) + // The 17-key base event: no `State`, `ImageName` a string, the broadcast key present. + expect(Object.keys(entry.PlayerEvent)).toHaveLength(17) + expect(entry.PlayerEvent).toMatchObject({ + PlayerEventId: open.PlayerEventId, + Name: 'Profile Open', + AttendeeCount: 2, + Accessibility: 1, + ImageName: '', + BroadcastingRoomInstanceId: null, + }) + expect(entry.PlayerEvent).not.toHaveProperty('State') + + // The host: their public unfinished events, soonest first, under both keys. + const host = await profile(610) + const ids = [maybe.PlayerEventId, open.PlayerEventId] + expect(host.Created.map((e) => e.PlayerEventId)).toEqual(ids) + expect(Object.keys(host.Created[0])).toHaveLength(17) + expect(host.Responses.map((r) => r.PlayerEvent.PlayerEventId)).toEqual(ids) + + // Nobody's events, and a non-numeric id doesn't match. + expect(await profile(999999)).toEqual({ Created: [], Responses: [] }) + expect((await get('/api/playerevents/v1/all/abc')).status).toBe(404) + }) + test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => { const respond = async (body: unknown, sub = '42'): Promise => post('/api/playerevents/v1/respond', body, sub) @@ -9785,6 +9856,7 @@ describe('openapi', () => { 'GET /api/playerReputation/v2/bulk', 'GET /api/playerevents/v1', 'GET /api/playerevents/v1/all', + 'GET /api/playerevents/v1/all/{playerId}', 'GET /api/playerevents/v1/bulk', 'GET /api/playerevents/v1/club/{clubId}', 'GET /api/playerevents/v1/clubs',