diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index 38c5e3c..e6865a9 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -478,6 +478,52 @@ export async function setEventResponse( return updated } +/** + * Add invited players to an event as Going — the bulk invite. Returns the updated + * event (with its recounted `AttendeeCount`) and the rows actually created, or null + * when there's no such event. + * + * An invite only ever INSERTS: a player who already has a row keeps the answer they + * gave, so being invited can't flip a decline back to Going, and re-inviting the same + * player is a no-op rather than a reset. Since the rows land as Going, the invited + * count toward `AttendeeCount` from the moment they're invited — see the route. + * + * `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict + * inserts nothing and returns nothing. That's what the route notifies on — a player + * whose existing answer was left alone gets no frame, because nothing changed for them. + * + * Ids are deduplicated by the composite primary key; an empty list is a no-op that + * still returns the event. + */ +export async function inviteToEvent( + db: D1Database, + eventId: number, + playerIds: number[] +): Promise<{ event: PlayerEvent; added: EventAttendeeRow[] } | null> { + const event = await getEventById(db, eventId) + if (event === null) return null + if (playerIds.length === 0) return { event, added: [] } + + const at = eventTime(Date.now()) + const inserts = await db.batch( + playerIds.map((playerId) => + db + .prepare( + `INSERT INTO event_attendee (event_id, player_id, status, responded_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (event_id, player_id) DO NOTHING + RETURNING rowid AS id, *` + ) + .bind(eventId, playerId, EVENT_RESPONSE.going, at) + ) + ) + const added = inserts.flatMap((r) => r.results) + + const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) } + await writeEvent(db, updated) + return { event: updated, added } +} + /** How many players said they're Going — an event's `AttendeeCount`. */ export async function countGoing(db: D1Database, eventId: number): Promise { const row = await db diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 567b5bb..7897ad4 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -469,6 +469,19 @@ export const PlayerEventDto = z.object({ CanRequestBroadcastPermissions: z.int(), }) +/** + * `GET /api/playerevents/v1/:eventId?includeDetails=True` — the record plus the one + * field the flag adds: the LOWERCASE `tags`, in an otherwise PascalCase record. Always + * empty, since no event tags are stored; the key is absent altogether when the flag + * isn't passed. The entry shape is the one the notification projection declares. + */ +export const PlayerEventDetailsDto = PlayerEventDto.extend({ + tags: z + .array(z.object({ tag: z.string(), type: z.int() })) + .optional() + .describe('Present only with `includeDetails=True`, and always empty'), +}) + /** * `GET /api/playerevents/v1` — the browse feed's listing. The same record minus * `State`, plus a `BroadcastingRoomInstanceId` (always null — nothing broadcasts an @@ -529,6 +542,14 @@ export const PlayerEventRespondRequest = z.object({ Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'), }) +/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */ +export const PlayerEventBulkInviteRequest = z.object({ + PlayerEventId: z.int(), + InvitedPlayerIds: z + .array(z.int()) + .describe('Ids to invite; duplicates and the caller are ignored'), +}) + /** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */ export const PlayerEventsAll = z.object({ Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'), diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index fa5859f..62db3ac 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -8,20 +8,22 @@ import { logger } from '@repo/hono-helpers' import { NotificationType } from '../../../notify/src/notification-types' import { createEvent, + eventInputRejection, getEventAttendees, getEventById, + getEventResponse, getEventsByClubs, getEventsByCreator, getEventsByIds, getLiveEvents, + inviteToEvent, isEventResponseType, - eventInputRejection, parseEventBody, searchEvents, setEventResponse, toEventListing, - toEventResponse, toEventNotification, + toEventResponse, toEventResult, updateEvent, } from '../events-db' @@ -33,6 +35,8 @@ import { json, jsonBody, pageParams, + PlayerEventBulkInviteRequest, + PlayerEventDetailsDto, PlayerEventDto, PlayerEventListingDto, PlayerEventRequest, @@ -47,8 +51,9 @@ import { } from '../openapi' import type { Context } from 'hono' +import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads' import type { App } from '../context' -import type { PlayerEvent } from '../events-db' +import type { EventAttendeeRow, PlayerEvent } from '../events-db' /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -76,6 +81,49 @@ async function notifyEventCreated(c: Context, event: PlayerEvent): Promise< } } +/** + * Push a `PlayerEventResponseChanged` (83) to each player a bulk invite just added — + * what puts the event on their screen without a refetch, since an invite writes their + * response row for them. + * + * Only the players who actually gained a row are notified: an invite that hit an + * existing answer changed nothing, so there is nothing to tell them about. + * + * The frame carries BOTH nested objects the client's decoder expects. That is not + * optional — several of its handlers dereference one level down with no null guard, so + * omitting one surfaces as a NullReferenceException in the client rather than a missing + * field (see notification-payloads.ts). The event goes in the same camelCase + * {@link toEventNotification} projection the `PlayerEventCreated` frame uses, and the + * response in the PascalCase {@link toEventResponse} one the RSVP list serves; the + * decoder accepts either casing, so the two need not agree. + * + * Hub failures are logged and swallowed, and one player's failure doesn't stop the + * rest: the invites are already stored by the time this runs. + */ +async function notifyInvited( + c: Context, + event: PlayerEvent, + added: EventAttendeeRow[] +): Promise { + const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE) + const PlayerEvent = { ...toEventNotification(event) } + for (const row of added) { + const payload = { + PlayerEvent, + PlayerEventResponse: { ...toEventResponse(row) }, + } satisfies PlayerEventResponsePayload + try { + await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload) + } catch (err) { + logger.error('failed to push PlayerEventResponseChanged notification', { + playerEventId: event.PlayerEventId, + playerId: row.player_id, + error: err instanceof Error ? err.message : String(err), + }) + } + } +} + /** * Player events — scheduled events players and clubs host in a room. * @@ -337,6 +385,74 @@ export const eventRoutes = new Hono({ strict: false }) } ) + // Bulk invite — the "invite friends" button on an event. Adds the invited players to + // the same `event_attendee` table an RSVP writes to, as Going. + .post( + '/api/playerevents/v1/bulkInvite', + describeRoute({ + tags: ['Events'], + summary: 'Invite players to an event', + description: + 'Adds the invited players to the event as Going — the same `event_attendee` rows ' + + 'an RSVP writes, so an invited player shows up in `…/responses` and counts toward ' + + '`AttendeeCount` immediately, without having answered.\n\n' + + 'An invite never overwrites an answer: a player who already responded keeps what ' + + 'they said, so inviting someone who declined does not flip them back to Going, and ' + + 're-inviting is a no-op. The caller is skipped (they are already on the list), as ' + + 'are duplicate ids.\n\n' + + 'The caller must be on the event themselves — its creator, or a player with a ' + + 'response row of any kind. Anyone else gets 403: an invite adds attendees, so it ' + + 'is not something a passer-by can do. Answers the same ' + + '`{ Result, TagModifyResult, PlayerEvent }` envelope the other event writes do, ' + + 'carrying the updated attendee count.', + security: AUTHED, + requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'), + responses: { + 200: json(PlayerEventResultDto, 'The event, with its updated attendee count'), + 400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'The caller is not on the event (empty body)' }, + 404: { description: 'No such event (empty body)' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = await c.req + .json<{ PlayerEventId?: unknown; InvitedPlayerIds?: unknown }>() + .catch(() => ({}) as { PlayerEventId?: unknown; InvitedPlayerIds?: unknown }) + const eventId = Number(body.PlayerEventId) + if (!Number.isInteger(eventId) || !Array.isArray(body.InvitedPlayerIds)) { + return c.body(null, 400) + } + + const event = await getEventById(c.env.DB, eventId) + if (event === null) return c.body(null, 404) + // On the event themselves, one way or the other. The creator has a Going row from + // create, so the response lookup would usually cover them — but it's checked + // explicitly so a creator who deleted their own answer can still invite. + if ( + event.CreatorPlayerId !== id && + (await getEventResponse(c.env.DB, eventId, id)) === null + ) { + return c.body(null, 403) + } + + // Unusable entries are dropped rather than failing the invite: a client sending one + // bad id shouldn't lose the other nine invites. + const invited = [ + ...new Set( + body.InvitedPlayerIds.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v !== id) + ), + ] + const result = await inviteToEvent(c.env.DB, eventId, invited) + // inviteToEvent only returns null when the row vanished, which the read above rules out. + await notifyInvited(c, result!.event, result!.added) + return c.json(toEventResult(result!.event)) + } + ) + // Create. The creator comes from the bearer token, never the body — posting someone // else's `CreatorPlayerId` doesn't make it theirs. .post( @@ -454,15 +570,26 @@ export const eventRoutes = new Hono({ strict: false }) summary: 'One player event', description: 'A single event by id, served as the bare record — no envelope, unlike the ' + - 'create/update writes. 404 when there is no such event.', - parameters: [idParam('eventId', 'Event id')], + 'create/update writes. 404 when there is no such event.\n\n' + + '`includeDetails=True` adds exactly one field, the lowercase `tags` — that is the ' + + 'whole of what the flag does. It is always an empty array here: no event tags are ' + + 'stored (see the tag-filter chips, which are static, and `TagModifyResult`, which ' + + 'is always null). Without the flag the key is ABSENT rather than empty, since a ' + + 'caller that didn’t ask for details shouldn’t be told the event has no tags.', + parameters: [ + idParam('eventId', 'Event id'), + stringQuery('includeDetails', 'Pass `True` to add the `tags` array'), + ], responses: { - 200: json(PlayerEventDto, 'The event'), + 200: json(PlayerEventDetailsDto, 'The event, with `tags` when details were asked for'), 404: { description: 'No such event (empty body)' }, }, }), async (c) => { const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10)) - return event === null ? c.body(null, 404) : c.json(event) + if (event === null) return c.body(null, 404) + // The client sends `True`; accepted case-insensitively, and `1` alongside it. + const details = /^(true|1)$/i.test(c.req.query('includeDetails') ?? '') + return c.json(details ? { ...event, tags: [] } : event) } ) diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index f6eafff..897828f 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -111,8 +111,8 @@ export const gameplayRoutes = new Hono({ strict: false }) 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 ' + + "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') }, }), diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 5ad7bae..095512f 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -3016,6 +3016,23 @@ describe('player events', () => { expect((await get('/api/playerevents/v1/999999')).status).toBe(404) }) + test('GET /api/playerevents/v1/:eventId?includeDetails=True adds only `tags`', async () => { + const path = `/api/playerevents/v1/${upcoming.PlayerEventId}` + // The flag's whole effect: the lowercase `tags`, empty (no event tags are stored). + expect(await (await get(`${path}?includeDetails=True`)).json()).toEqual({ + ...upcoming, + tags: [], + }) + // Accepted case-insensitively — the client sends `True`. + expect(await (await get(`${path}?includeDetails=true`)).json()).toEqual({ + ...upcoming, + tags: [], + }) + // Anything else is the bare record, with no `tags` key at all. + expect(await (await get(`${path}?includeDetails=False`)).json()).toEqual(upcoming) + expect(await (await get(path)).json()).toEqual(upcoming) + }) + test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => { const res = await get( `/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}` @@ -3246,6 +3263,118 @@ describe('player events', () => { ).toBe(404) }) + test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => { + const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) }) + const id = event.PlayerEventId + + // 43 declines BEFORE being invited — the invite must not flip that back. + await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '43') + + const res = await post( + '/api/playerevents/v1/bulkInvite', + // 42 is the caller (already on the event) and 187 is repeated — both are skipped. + { PlayerEventId: id, InvitedPlayerIds: [187, 2, 187, 42, 43] }, + '42' + ) + expect(res.status).toBe(200) + const body = (await res.json()) as PlayerEventResult + expect(body.Result).toBe(0) + // The creator plus the two newly invited — 43 keeps their decline, so isn't counted. + expect(body.PlayerEvent.AttendeeCount).toBe(3) + + const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{ + PlayerId: number + Type: number + }> + expect( + responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type]) + ).toEqual([ + [2, 0], + [42, 0], + [43, 2], + [187, 0], + ]) + + // Re-inviting is a no-op, not a reset: 43 still declines and the count holds. + const again = await post( + '/api/playerevents/v1/bulkInvite', + { PlayerEventId: id, InvitedPlayerIds: [187, 43] }, + '42' + ) + expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3) + + // An empty list is a no-op that still answers the event. + const none = await post('/api/playerevents/v1/bulkInvite', { + PlayerEventId: id, + InvitedPlayerIds: [], + }) + expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3) + }) + + test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => { + const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const event = await create({ RoomId: 3, Name: 'Invite Frames', StartTime: at(HOUR) }) + const id = event.PlayerEventId + // 43 answers first, so the invite leaves them alone — and must not notify them. + await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43') + + await hub.fetch('http://do/all', { method: 'DELETE' }) + await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [2, 43] }) + const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{ + playerId: number + notificationType: number + data: Record> + }> + + // One frame, to the one player who gained a row. 43 kept their answer, so nothing + // changed for them and nothing is pushed. + expect(sent).toHaveLength(1) + expect(sent[0]!.playerId).toBe(2) + expect(sent[0]!.notificationType).toBe(83) // PlayerEventResponseChanged + + // BOTH nested objects are present — the client dereferences them without a null + // guard, so a missing one is a NullReferenceException rather than a blank field. + expect(sent[0]!.data.PlayerEvent).toMatchObject({ + playerEventId: id, + name: 'Invite Frames', + attendeeCount: 2, + }) + expect(sent[0]!.data.PlayerEventResponse).toEqual({ + PlayerEventResponseId: expect.any(Number), + PlayerEventId: id, + PlayerId: 2, + CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/), + Type: 0, + }) + }) + + test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => { + const event = await create({ RoomId: 3, Name: 'Invite Gate', StartTime: at(HOUR) }) + const id = event.PlayerEventId + const invite = async (body: unknown, sub = '42'): Promise => + post('/api/playerevents/v1/bulkInvite', body, sub) + + expect( + ( + await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulkInvite`, { + method: 'POST', + body: JSON.stringify({ PlayerEventId: id, InvitedPlayerIds: [2] }), + }) + ).status + ).toBe(401) + + // 44 has no response row on this event — not theirs to invite to. + expect((await invite({ PlayerEventId: id, InvitedPlayerIds: [2] }, '44')).status).toBe(403) + // …until they respond, which puts them on it. + await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '44') + expect((await invite({ PlayerEventId: id, InvitedPlayerIds: [2] }, '44')).status).toBe(200) + + expect((await invite({ PlayerEventId: 999999, InvitedPlayerIds: [2] })).status).toBe(404) + expect((await invite({ InvitedPlayerIds: [2] })).status).toBe(400) + expect((await invite({ PlayerEventId: id })).status).toBe(400) + expect((await invite({})).status).toBe(400) + }) + test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => { const event = await create({ RoomId: 5, @@ -3422,6 +3551,7 @@ describe('openapi', () => { 'POST /api/messages/v2/send', 'POST /api/playerReputation/v1/bulk', 'POST /api/playerReputation/v2/bulk', + 'POST /api/playerevents/v1/bulkInvite', 'POST /api/playerevents/v1/respond', 'POST /api/playerevents/v2', 'POST /api/playerevents/v2/{eventId}',