[events] account events endpoint

This commit is contained in:
Devin Zuczek
2026-09-30 23:55:09 -04:00
parent 4319f2942b
commit 0636f535f3
4 changed files with 177 additions and 0 deletions
+67
View File
@@ -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<PlayerPublicEvents> {
const at = eventTime(now)
const [created, responses] = await db.batch<EventRow & Partial<EventAttendeeRow>>([
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
+11
View File
@@ -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'),
+27
View File
@@ -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<App>({ 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
+72
View File
@@ -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<Response> =>
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
interface Profile {
Created: PlayerEvent[]
Responses: Array<{
PlayerEvent: Record<string, unknown>
PlayerEventResponse: Record<string, unknown>
}>
}
const profile = async (playerId: number | string): Promise<Profile> => {
// 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<Response> =>
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',