[events] add bulkInvite

This commit is contained in:
Devin Zuczek
2026-08-12 17:17:10 -04:00
parent 385e10bd55
commit 3aad586153
5 changed files with 333 additions and 9 deletions
+46
View File
@@ -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<EventAttendeeRow>(
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<number> {
const row = await db
+21
View File
@@ -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 Cant 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'),
+134 -7
View File
@@ -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<App>, 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<App>,
event: PlayerEvent,
added: EventAttendeeRow[]
): Promise<void> {
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<App>({ 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<App>({ 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 didnt ask for details shouldnt 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)
}
)
+2 -2
View File
@@ -111,8 +111,8 @@ export const gameplayRoutes = new Hono<App>({ 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') },
}),
+130
View File
@@ -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<string, Record<string, unknown>>
}>
// 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<Response> =>
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}',