Files
recflare/apps/api/src/routes/events.ts
T

1091 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { Accessibility, GAME_VERSION } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
import { validateAndGetVersion } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import {
createEvent,
deleteEvent,
EVENT_DELETED_RESULT,
eventInputRejection,
getEventAttendees,
getEventById,
getEventResponse,
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getEventsByRoom,
getEventTags,
getLiveEvents,
inviteToEvent,
isEventResponseType,
parseEventBody,
parseEventTags,
parseEventTime,
searchEvents,
setEventResponse,
toEventBase,
toEventNotification,
toEventResponse,
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, queryIds, unauthorized } from '../http'
import {
AUTHED,
form,
idParam,
intQuery,
json,
jsonBody,
pageParams,
PlayerEventAccessibilityRequest,
PlayerEventBaseDto,
PlayerEventBulkInviteRequest,
PlayerEventDeletedDto,
PlayerEventDescriptionRequest,
PlayerEventDetailsDto,
PlayerEventDto,
PlayerEventNameRequest,
PlayerEventReportRequest,
PlayerEventRequest,
PlayerEventRespondRequest,
PlayerEventResponseDto,
PlayerEventResultDto,
PlayerEventsAll,
PlayerEventsPage,
PlayerEventTagsRequest,
PlayerEventTimeRequest,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import type { Context } from 'hono'
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Push a `PlayerEventCreated` notification for a freshly scheduled event to its
* creator — what makes the event appear on their own screen without a refetch.
*
* Hub failures are logged and swallowed: the event is already stored, so a hub hiccup
* must not fail the create. Note the frame carries the camelCase
* {@link toEventNotification} projection, not the PascalCase record the response does.
*/
async function notifyEventCreated(
c: Context<App>,
event: PlayerEvent,
tags: EventTag[]
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
event.CreatorPlayerId,
NotificationType.PlayerEventCreated,
{ ...toEventNotification(event, tags) }
)
} catch (err) {
logger.error('failed to push PlayerEventCreated notification', {
playerEventId: event.PlayerEventId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 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),
})
}
}
}
/**
* Wrap an event in the v2 envelope for THIS caller's build.
*
* Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint
* owes the 2023 build `[{ Tag, Type }]` and the 2025 build `["celebration"]`. The build
* comes off the token's `rn.ver` claim — the request carries no version of its own — and
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
* `GAME_VERSION` (20230414) is the 2025 client; that build, anything older, and a request
* with no readable token version all get the 2023 shape. Builds are date-stamped, so they
* order as strings.
*
* Like the other version gates here the claim is unverified — a client that lies about its
* build only empties its own tag chips.
*/
async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const isModernBuild = version !== null && version > GAME_VERSION
return toEventResult(event, tags, !isModernBuild)
}
/**
* The shared front half of the single-field event edits (`PUT …/v2/{id}/{field}`):
* authenticate, load the event, check the caller created it, then apply whatever patch
* `parse` reads out of the body and answer the same `{ Result, TagModifyResult,
* PlayerEvent }` envelope the other v2 writes do — the client re-renders the event from
* the response rather than refetching it.
*
* `parse` answers `null` to refuse the body, which becomes the empty-bodied 400 the rest
* of this file uses. It gets the stored event so a rule can depend on it (the time edit
* checks the new window against the bound it isn't changing).
*
* These edits are creator-only like the whole-event update, and they go through the same
* {@link updateEvent}, so a patch touching one field leaves the rest of the event alone.
*/
function editEventField(
parse: (c: Context<App>, event: PlayerEvent) => Promise<EventInput | null>
) {
return async (c: Context<App>) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// `?? ''` only to satisfy the untyped-path signature — the route pattern already
// constrains the segment to digits, so it is always there.
const eventId = Number.parseInt(c.req.param('eventId') ?? '', 10)
const existing = await getEventById(c.env.DB, eventId)
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
const input = await parse(c, existing)
if (input === null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
}
}
/** The form body of a single-field edit; an unparseable one reads as empty. */
async function formBody(c: Context<App>): Promise<Record<string, unknown>> {
return (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
}
/**
* Parse an `accessibility` field into an {@link Accessibility} value. The client sends
* the enum NAME here (`accessibility=Unlisted`), as it does on the subroom route in
* `rooms`; the ordinal is accepted alongside it. Undefined when the field names nothing
* in the enum — which the route refuses rather than defaulting, since guessing a
* visibility wrong is the kind of mistake that shows a private event to everyone.
*/
function parseEventAccessibility(value: unknown): number | undefined {
if (typeof value !== 'string') return undefined
const raw = value.trim()
const named = Object.entries(Accessibility).find(
([name, ordinal]) => typeof ordinal === 'number' && name.toLowerCase() === raw.toLowerCase()
)
if (named) return named[1] as number
if (!/^\d+$/.test(raw)) return undefined
const ordinal = Number.parseInt(raw, 10)
return ordinal in Accessibility ? ordinal : undefined
}
/**
* Player events — scheduled events players and clubs host in a room.
*
* D1-backed (the `event` table, owned by this worker; see events-db.ts). The stored
* blob IS the DTO, so every read here serves it verbatim; only the create/update
* writes wrap it, in the `{ Result, TagModifyResult, PlayerEvent }` envelope.
*
* Watch the response shapes: the two club feeds deliberately differ (bare array for
* the multi-club form, paged envelope for the single-club one) and the client chokes
* if they're unified.
*/
export const eventRoutes = new Hono<App>({ strict: false })
// The player-events browse feed — everything upcoming or running, soonest first. Same
// query `/search` runs with no text, but its own projection: the feed serves the client's
// BASE event (17 keys — no `State`, a string `ImageName`, plus `BroadcastingRoomInstanceId`),
// which is the v2 envelope's event minus `Tags`. Hence `toEventBase`.
.get(
'/api/playerevents/v1',
describeRoute({
tags: ['Events'],
summary: 'The player-events browse feed',
description:
'The default feed on the player-events screen: every event that has not finished ' +
'yet — upcoming and running — soonest first, paginated via skip/take. A bare ' +
'array.\n\n' +
'Each entry is the clients BASE event — the v2 envelopes event minus `Tags`, 17 ' +
'keys — not the stored record the by-id, bulk and search reads serve: it drops ' +
'`State`, serves `ImageName` as `""` rather than null, and carries ' +
'`BroadcastingRoomInstanceId` (always null — nothing broadcasts an event yet). ' +
'That is the shape observed on this endpoint; keep the two projections apart.',
parameters: pageParams(50),
responses: { 200: json(PlayerEventBaseDto.array(), 'The events that have not ended') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
const events = await searchEvents(c.env.DB, '', skip, take)
return c.json(events.map(toEventBase))
}
)
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Events'],
summary: 'The callers player events',
description:
'Events the player created and events they have RSVPd to. `Created` is served ' +
'from the event table, soonest first.\n\n' +
'`Responses` is still always empty. RSVPs ARE stored now (see ' +
'`/api/playerevents/v1/respond` and the `event_attendee` table) — what isnt known ' +
'is the shape this field wants: whether an entry is a bare event like `Created`, ' +
'or the event plus the answer, which is the useful thing to render. Serving the ' +
'wrong one renders nothing rather than erroring, so it stays empty until a real ' +
'response is observed.',
security: AUTHED,
responses: {
200: json(PlayerEventsAll, 'The callers created events, and an empty RSVP list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] })
}
)
// 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
// recent-activity data we don't keep, and the client renders no trending row for null.
.get(
'/api/playerevents/v1/tagfilters',
describeRoute({
tags: ['Events'],
summary: 'Player-event filter chips',
description:
'The filter chips on the player-events browse screen — the event categories the ' +
'client offers. Static: the same set regardless of what is stored. ' +
'`TrendingFilters` is null even in the reference (it needs recent-activity data), ' +
'and the client renders no trending row for null.',
security: AUTHED,
responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
PinnedFilters: [
'workshops',
'celebration',
'game',
'meetup',
'performance',
'coop',
'grandopening',
'class',
'competition',
],
PopularFilters: [
'workshops',
'celebration',
'class',
'coop',
'competition',
'game',
'grandopening',
'meetup',
'performance',
],
TrendingFilters: null,
})
}
)
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
// page. A bare array: the client deserializes this one as a list, and chokes on the
// `{ ContinuationToken, Events }` envelope the single-club form uses.
.get(
'/api/playerevents/v1/clubs',
describeRoute({
tags: ['Events'],
summary: 'Player events across several clubs',
description:
'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' +
'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' +
'paged envelope the single-club form below uses. Do not unify the two. No ids ' +
'means an empty shelf, not every event.',
parameters: [intQuery('id', 'Repeatable club id')],
responses: { 200: json(PlayerEventDto.array(), 'The clubs events') },
}),
async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c)))
)
// The same feed for a single club (`/club/1`) — the form the reference serves,
// which *does* wrap the events with a paging cursor (empty = no next page).
.get(
'/api/playerevents/v1/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Player events for one club',
description:
'The same feed for a single club — and this form DOES wrap the events with a ' +
'paging cursor, matching the reference. The cursor is always empty: a clubs event ' +
'list is small enough to serve in one page.',
parameters: [idParam('clubId', 'Club id')],
responses: { 200: json(PlayerEventsPage, 'The clubs events, in a single page') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const events = await getEventsByClubs(c.env.DB, [clubId])
return c.json({ ContinuationToken: '', Events: events })
}
)
// A room's event shelf (`/room/12`) — what is on in this room, current and upcoming.
// A bare array of the stored record, like the multi-club shelf and `/searchlive`: the
// single-club form's `{ ContinuationToken, Events }` envelope is the odd one out, and a
// room's shelf is small enough that there is nothing to page.
.get(
'/api/playerevents/v1/room/:roomId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Player events in one room',
description:
'The events scheduled in a room — the shelf on the rooms page — soonest first. A ' +
'bare array of the stored record, the same projection `/searchlive` and the ' +
'multi-club shelf serve.\n\n' +
'CURRENT and UPCOMING only: the filter is on the END time, so a running event stays ' +
'listed until it is over rather than vanishing the moment it starts, and an event ' +
'that has finished is dropped — this answers what someone can still turn up to. A ' +
'room with nothing scheduled, and a room id that does not exist, both answer an ' +
'empty array; the shelf is about events, not about whether the room is real.',
parameters: [idParam('roomId', 'Room id')],
responses: { 200: json(PlayerEventDto.array(), 'The rooms current and upcoming events') },
}),
async (c) => c.json(await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
)
// Live player-event search (the "happening now" browse query) — events that have
// started and not yet finished. A bare array, like the multi-club feed.
.get(
'/api/playerevents/v1/searchlive',
describeRoute({
tags: ['Events'],
summary: 'Live player events',
description:
'The "happening now" row on the player-events browse screen: events that have ' +
'started and not yet ended, soonest first. A bare array.',
responses: { 200: json(PlayerEventDto.array(), 'The events running right now') },
}),
async (c) => c.json(await getLiveEvents(c.env.DB))
)
// Event search — the browse query. Text is matched term by term against name and
// description; finished events are left out (this backs a browse screen).
.get(
'/api/playerevents/v1/search',
describeRoute({
tags: ['Events'],
summary: 'Search player events',
description:
'The browse query on the player-events screen, term by term; an empty query ' +
'browses everything upcoming. A `#` decides how a term is matched: `#workshops` is ' +
'a TAG term, matching only events tagged `workshops` and never the word in a name ' +
'or description, which is what the filter chips send; a bare `workshops` is TEXT, ' +
'matched case-insensitively against the name and description. Every term must ' +
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
'Events that have already finished are left out — a name match on something that ' +
'ended last month is noise on a browse screen. Soonest first, paginated via ' +
'skip/take. A bare array.',
parameters: [
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
stringQuery(
'sort',
'Accepted and echoed by the client as `StartTime`, which is the only order ' +
'served (soonest first); any other value sorts the same way'
),
...pageParams(50),
],
responses: { 200: json(PlayerEventDto.array(), 'The matching events') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
}
)
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
// holds. Answers in the order asked for; ids with no event are skipped.
.get(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id',
description:
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
'in the order the ids were asked for — the client renders them in request order — ' +
'and skips ids with no event rather than leaving a hole, so the result may be ' +
'shorter than the request. A bare array.',
parameters: [intQuery('id', 'Repeatable event id')],
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
}),
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
)
// RSVP. One row per player per event, so responding again replaces the previous
// answer rather than stacking up. Note this is the v1 path while create/update are
// v2 — that's how the client calls them.
.post(
'/api/playerevents/v1/respond',
describeRoute({
tags: ['Events'],
summary: 'Answer a player event',
description:
'Records how the caller is answering an event — `Type` is 0 Going, 1 Interested, ' +
'2 Cant go. Responding again replaces the previous answer; there is one row per ' +
'player per event, and a decline is recorded rather than deleted so the client can ' +
'show a player what they said.\n\n' +
'Only Going counts toward the events `AttendeeCount`, which is recomputed from ' +
'the RSVP table on every response. Anyone may respond, the creator included — ' +
'they are already Going from create, and nothing stops them declining their own ' +
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
'v2 writes do, carrying the event with its updated count, so the client can ' +
're-render from the response.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 02, is a 400; an ' +
'unknown event is a 404.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
responses: {
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
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; Type?: unknown }>()
.catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown })
const eventId = Number(body.PlayerEventId)
const type = Number(body.Type)
// Both are rejected rather than defaulted: an unrecognized answer stored as
// Going would silently inflate the count.
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
const updated = await setEventResponse(c.env.DB, eventId, id, type)
if (updated === null) return c.body(null, 404)
return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId)))
}
)
// Report an event. Stored in the `report` table the player reports use — same fields,
// same moderation life — with `event_id` set. See migrations/0011_report_event.sql.
.post(
'/api/playerevents/v1/report',
describeRoute({
tags: ['Events', 'Moderation'],
summary: 'Report a player event',
description:
'Files a report against an event. Stored as a row in the same `report` table a ' +
'player report goes to (`POST /api/PlayerReporting/v3/create`) — it is the same ' +
'submission with the same moderation life, and a moderator converts either into a ' +
'ban the same way. What marks it as an event report is `event_id`; the rows ' +
'`reported_player_id` is the events CREATOR (who a moderator would act against) ' +
'and its `room_id` the room the event runs in, both read from the event rather ' +
'than sent by the client.\n\n' +
'The reporter is the caller (from the bearer token), never a body field. Note this ' +
'body is JSON, where the player reports is form-encoded. `ReportCategory` is ' +
'stored verbatim — the enum is not mapped here. Nothing dedupes the rows: ' +
'reporting the same event twice files two reports.\n\n' +
'Answers the same `{ success, error }` envelope as the player report, `error` ' +
'being an empty string rather than null, on the rejected branches too so there is ' +
'only one shape to parse.',
security: AUTHED,
requestBody: jsonBody(PlayerEventReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable `PlayerEventId` in the body'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such event'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req
.json<{ PlayerEventId?: unknown; ReportCategory?: unknown; Details?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const eventId = Number(body.PlayerEventId)
if (!Number.isInteger(eventId)) {
return c.json({ success: false, error: 'PlayerEventId is required' }, 400)
}
// The event supplies the two columns the client doesn't send. An unknown event is
// refused rather than filed against nobody: the row's reported player has to be
// someone, and a report naming an event that never existed isn't actionable.
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.json({ success: false, error: 'No such event' }, 404)
const category = Number(body.ReportCategory)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: event.CreatorPlayerId,
reportCategory: Number.isInteger(category) ? category : 0,
details: typeof body.Details === 'string' ? body.Details : null,
roomId: event.RoomId > 0 ? event.RoomId : null,
eventId,
})
return c.json({ success: true, error: '' })
}
)
// 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(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId)))
}
)
// Create. The creator comes from the bearer token, never the body — posting someone
// else's `CreatorPlayerId` doesn't make it theirs.
.post(
'/api/playerevents/v2',
describeRoute({
tags: ['Events'],
summary: 'Create a player event',
description:
'Schedules a new event. The creator is taken from the bearer token, never the ' +
'body; the id is assigned here. Lenient about the rest, like the other writes ' +
'here — a missing name becomes “Untitled Event” and a missing time window becomes ' +
'an hour from now, rather than an error the client cant render.\n\n' +
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' +
'the bare event the read endpoints serve.\n\n' +
'The window is capped at 24 hours and must end after it starts — an event is a ' +
'scheduled get-together, not a season. Since a missing end defaults to an hour ' +
'after the start, only a body naming both bounds (or an end alone, which is ' +
'measured from now) can fail this.\n\n' +
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
'the event in its camelCase notification projection. A hub failure is logged and ' +
'swallowed — the event is already stored by then.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
responses: {
200: json(PlayerEventResultDto, 'The created event'),
400: {
description:
'Name over 64 or description over 512 characters, or a window that is ' +
'backwards or longer than 24 hours (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
// The one thing this route isn't lenient about. Everything else here defaults a
// missing or unusable field, but a name or description past the stored length
// can't be defaulted into something sensible — and truncating a player's event
// description silently is worse than refusing it.
if (eventInputRejection(input) !== null) return c.body(null, 400)
const event = await createEvent(c.env.DB, id, input)
await notifyEventCreated(c, event, input.tags ?? [])
// Read the tags back rather than echoing what was posted: the envelope reports what
// the event now carries, which is what the client redraws its chips from.
return c.json(await eventResult(c, event, await getEventTags(c.env.DB, event.PlayerEventId)))
}
)
// Delete an event. Creator-only, and it takes the RSVPs and tags with it — a cancelled
// event that left its `event_attendee` rows behind would keep being counted, and its
// `event_tag` rows would keep answering `#tag` searches for an event nobody can open.
//
// Registered for POST and DELETE both: the path spells the verb itself (`/delete/{id}`),
// which is how the reference exposes it, and a client that reaches for the HTTP verb
// instead should not get a 404 for being right.
//
// Answers the v2 envelope with both payload fields nulled —
// `{ PlayerEvent: null, Result: 0, TagModifyResult: null }`, which is what the reference
// sends: there is nothing left to redraw. An unknown event is 404, and someone else's 403.
.on(
['POST', 'DELETE'],
'/api/playerevents/v2/delete/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Delete a player event',
description:
'Deletes an event the caller created, along with its RSVPs and its tags — an event ' +
'whose attendee rows outlived it would still be counted, and its tags would still ' +
'answer `#tag` searches.\n\n' +
'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' +
'envelope with `PlayerEvent` and `TagModifyResult` both null — the event is gone, ' +
'so there is nothing for the client to redraw from, and it reads only `Result`. ' +
'Both POST and DELETE reach it — the path names the verb, which is the form the ' +
'client uses.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
responses: {
200: json(PlayerEventDeletedDto, 'The nulled envelope a delete answers with'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const existing = await getEventById(c.env.DB, eventId)
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
await deleteEvent(c.env.DB, eventId)
// Both payload fields are null here — the delete envelope is not the one the other
// v2 routes answer with. Nothing is left to redraw, and the client reads `Result`.
return c.json(EVENT_DELETED_RESULT)
}
)
// Read one event in the v2 envelope — the same `{ PlayerEvent, Result, TagModifyResult }`
// the writes answer, so a client that just created or edited an event and one that is
// opening it cold parse the same thing.
//
// The v1 read next to it stays the BARE record on purpose: it is a different shape for a
// different caller (no envelope, `State` present, tags only behind `includeDetails`).
// Two shapes of one event; don't unify them.
.get(
'/api/playerevents/v2/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'One player event (v2 envelope)',
description:
'A single event wrapped in the same `{ PlayerEvent, Result, TagModifyResult }` ' +
'envelope the v2 writes answer with — `Tags` inline, `BroadcastingRoomInstanceId` ' +
'present, no `State`. 404 when there is no such event.\n\n' +
'`TagModifyResult` carries the events tags here too, even though a read edits ' +
'nothing: the client reads its chips out of that field either way.',
parameters: [idParam('eventId', 'Event id')],
responses: {
200: json(PlayerEventResultDto, 'The event in the v2 envelope'),
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.body(null, 404)
return c.json(await eventResult(c, event, await getEventTags(c.env.DB, eventId)))
}
)
// Update. Creator-only, and a partial body only changes what it carries.
.post(
'/api/playerevents/v2/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Update a player event',
description:
'Edits an event the caller created. Only the fields the body carries change; ' +
'everything else keeps its stored value, so a partial post cant blank out the ' +
'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' +
'clear it.\n\n' +
'The id, the creator and the attendee count are not editable: ownership doesnt ' +
'transfer and RSVPs arent set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.\n\n' +
'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' +
'carries: moving the start alone still has to leave a window that ends after it ' +
'and runs no longer than a day against the STORED end.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: {
description:
'Name over 64 or description over 512 characters, or a resolved window that ' +
'is backwards or longer than 24 hours (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const existing = await getEventById(c.env.DB, eventId)
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
// The stored event is passed so the window rule resolves against the bound this
// post isn't moving: an edit that only shifts the start still has to land inside
// a day of the end already stored.
if (eventInputRejection(input, existing) !== null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
}
)
// ---- Single-field edits -------------------------------------------------
// The event-settings screen edits one field at a time rather than posting the whole
// event back, so each of these is a PUT alongside the whole-event update above. They
// share its rules — creator-only, 404/403/401 the same way — and answer the same v2
// envelope, which is what the client re-renders the event from.
//
// Note the bodies are FORM-encoded (the tags one excepted), where the whole-event
// writes next to them are JSON. That is what the client sends; don't unify them.
// Move an event's window. Either bound alone is enough — an absent one keeps its
// stored value, so the start can be nudged without restating the end.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/time',
describeRoute({
tags: ['Events'],
summary: 'Reschedule a player event',
description:
'Moves an events window. `startTime` and `endTime` are both optional and both ' +
'independent: an absent bound keeps the stored one, so the start can be nudged ' +
'without restating the end. Any parseable ISO 8601 is accepted — the client sends ' +
'.NET tick precision (`2026-08-31T17:30:00.0000000Z`) — and stored trimmed to ' +
'seconds, the form every read serves.\n\n' +
'A bound that is present but unparseable is a 400 rather than being dropped: a ' +
'reschedule that silently did nothing is worse than a refusal. So is a window that ' +
'ends before it starts, or one running longer than 24 HOURS — an event lasts at ' +
'most a day. Both are checked against the RESOLVED window, so sending one bound ' +
'is measured against the stored other one.\n\n' +
'Creator only, like the whole-event update; answers the same v2 envelope.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: form(PlayerEventTimeRequest, 'The new window'),
responses: {
200: json(PlayerEventResultDto, 'The rescheduled event'),
400: {
description:
'An unparseable time, an end before the start, or a window over 24 hours ' +
'(empty body)',
},
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c, event) => {
const body = await formBody(c)
// Absent leaves the stored bound alone; present-but-unusable is refused, which is
// the distinction `parseEventBody` deliberately collapses for the JSON writes.
const startTime = parseEventTime(body.startTime)
const endTime = parseEventTime(body.endTime)
if (body.startTime !== undefined && startTime === undefined) return null
if (body.endTime !== undefined && endTime === undefined) return null
// The window rules — ends after it starts, runs no longer than a day — live with
// the other write validation, resolved against the bound this edit isn't moving.
const input = { startTime, endTime }
return eventInputRejection(input, event) === null ? input : null
})
)
// Change an event's visibility. The NAME of the enum, as the subroom route in `rooms`
// takes it — not the ordinal the event's JSON writes carry.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/accessibility',
describeRoute({
tags: ['Events'],
summary: 'Set a player events accessibility',
description:
'Sets an events visibility. The client sends the `RoomAccessibility` NAME here ' +
'(`accessibility=Unlisted`), the way it does on the subroom route in `rooms` — not ' +
'the ordinal the events JSON writes carry, though the ordinal is accepted too.\n\n' +
'A value naming nothing in the enum is a 400 rather than being defaulted or stored ' +
'verbatim: guessing a visibility wrong is what shows a private event to everyone. ' +
'Creator only; answers the same v2 envelope.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: form(PlayerEventAccessibilityRequest, 'The new visibility'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: { description: 'Missing or unrecognized `accessibility` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c) => {
const accessibility = parseEventAccessibility((await formBody(c)).accessibility)
return accessibility === undefined ? null : { accessibility }
})
)
// Replace an event's tags. A BARE JSON ARRAY of names, unlike the other edits here.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/tags',
describeRoute({
tags: ['Events'],
summary: 'Set a player events tags',
description:
'Replaces an events whole tag set. The body is a BARE JSON ARRAY of names — ' +
'`["tag1","class"]` — not the form encoding the other single-field edits use, and ' +
'not an object; the `{ tag, type }` pairs the create/update bodies accept work too. ' +
'A replace, not a merge: untagging is a PUT with the tag left out, and `[]` clears ' +
'them all.\n\n' +
'Names are lowercased and a leading `#` stripped, matching what the `#tag` search ' +
'looks for. A body that is not an array is a 400. Creator only; answers the same v2 ' +
'envelope, whose `TagModifyResult` carries the set the event now has.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: jsonBody(PlayerEventTagsRequest, 'The whole tag set'),
responses: {
200: json(PlayerEventResultDto, 'The updated event, with its new tags'),
400: { description: 'The body is not a JSON array (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c) => {
const body = await c.req.json<unknown>().catch(() => undefined)
const tags = parseEventTags(body)
return tags === undefined ? null : { tags }
})
)
// Rewrite an event's blurb. An absent field clears it — the client sends no field for
// an emptied box, like the room description route in `rooms`.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/description',
describeRoute({
tags: ['Events'],
summary: 'Set a player events description',
description:
'Rewrites an events blurb. An absent `description` CLEARS it — an emptied text box ' +
'sends no field, the same way the room description route in `rooms` behaves — so ' +
'this is the one edit here that cant be a no-op.\n\n' +
'Capped at 512 characters, the stored length, and refused rather than truncated: ' +
'silently cutting a players text off is worse than telling them. Creator only; ' +
'answers the same v2 envelope.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: form(PlayerEventDescriptionRequest, 'The new description'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: { description: 'Description over 512 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c) => {
const raw = (await formBody(c)).description
const description = typeof raw === 'string' ? raw : ''
return eventInputRejection({ description }) === null ? { description } : null
})
)
// Rename an event. Unlike the description, a blank one is refused: `updateEvent` reads
// an empty name as "leave it alone", so storing one is impossible anyway — and an event
// with no title renders as a blank row.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/name',
describeRoute({
tags: ['Events'],
summary: 'Rename a player event',
description:
'Retitles an event. Capped at 64 characters, the stored length, and refused rather ' +
'than truncated. A blank name is refused too — an event with no title renders as a ' +
'blank row, and the whole-event update reads an empty name as “leave it alone”, so ' +
'there is no way to store one regardless. The name is stored trimmed.\n\n' +
'No uniqueness rule: two events may share a title, unlike a room name. Creator ' +
'only; answers the same v2 envelope.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: form(PlayerEventNameRequest, 'The new title'),
responses: {
200: json(PlayerEventResultDto, 'The renamed event'),
400: { description: 'A blank name, or one over 64 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c) => {
const raw = (await formBody(c)).name
const name = typeof raw === 'string' ? raw.trim() : ''
if (name === '' || eventInputRejection({ name }) !== null) return null
return { name }
})
)
// An event's guest list — every RSVP row, whatever the answer.
.get(
'/api/playerevents/v1/:eventId{[0-9]+}/responses',
describeRoute({
tags: ['Events'],
summary: 'An events RSVPs',
description:
'Every answer given to an event, in the order they were given — declines and ' +
'maybes included, not just the Going rows `AttendeeCount` counts. One entry per ' +
'player: a player who changed their mind has one row carrying the answer that ' +
'stands, and `CreatedAt` moves with it.\n\n' +
'A bare array, and an unknown event is an empty one rather than a 404 — like the ' +
'other list reads here. An event always has at least its creators Going row.',
parameters: [idParam('eventId', 'Event id')],
responses: { 200: json(PlayerEventResponseDto.array(), 'The events RSVPs') },
}),
async (c) => {
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const attendees = await getEventAttendees(c.env.DB, eventId)
return c.json(attendees.map(toEventResponse))
}
)
// A single event. Registered last so the literal `/bulk` and `/search` paths above
// are matched first; the `[0-9]+` constraint keeps them apart regardless.
.get(
'/api/playerevents/v1/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
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.\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(PlayerEventDetailsDto, 'The event, with `tags` when details were asked for'),
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const event = await getEventById(c.env.DB, eventId)
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') ?? '')
if (!details) return c.json(event)
return c.json({ ...event, tags: await getEventTags(c.env.DB, eventId) })
}
)