mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[events] add bulkInvite
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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') },
|
||||
}),
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user