Files
recflare/apps/api/src/routes/events.ts
T
2026-09-23 17:14:04 -04:00

1321 lines
58 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, createNotification, GAME_VERSION, MessageType } 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,
getGoingPlayerIds,
getLiveEvents,
inviteToEvent,
isEventResponseType,
parseEventBody,
parseEventTags,
parseEventTime,
searchEvents,
setEventResponse,
toEventBase,
toEventNotification,
toEventResponse,
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import {
AUTHED,
BulkIdsRequest,
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 { StoredNotification } from '@repo/domain'
import type {
PlayerEventIdPayload,
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 one frame to every player who is GOING to an event — the audience for a change
* to the event itself. Only Going rows (see {@link getGoingPlayerIds}): a player who is
* merely invited, interested or declined has nothing on screen to update. The creator
* is among them through their own Going row, so their own edit or delete reaches their
* client too, the way `PlayerEventCreated` does.
*
* `playerIds` is passed in rather than read here because a delete has to read them
* BEFORE the rows go. Hub failures are logged and swallowed, and one player's failure
* doesn't stop the rest: the change is already stored by the time this runs.
*/
async function notifyGoing(
c: Context<App>,
playerIds: number[],
type: NotificationType,
payload: Record<string, unknown>,
playerEventId: number
): Promise<void> {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
for (const playerId of playerIds) {
try {
await hub.notifyPlayer(playerId, type, payload)
} catch (err) {
logger.error('failed to push player event notification', {
notificationType: type,
playerEventId,
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
/**
* Push a `PlayerEventUpdated` (81) to everyone Going to an event that was just edited —
* the whole-event update and every single-field edit alike — carrying the event as it
* now stands, in the same camelCase {@link toEventNotification} projection the
* `PlayerEventCreated` frame uses: the two share a payload shape on the client
* (`PlayerEventPayload` in notification-payloads.ts documents its fields; the decoder
* accepts either casing).
*/
async function notifyEventUpdated(
c: Context<App>,
event: PlayerEvent,
tags: EventTag[]
): Promise<void> {
const payload: Record<string, unknown> = { ...toEventNotification(event, tags) }
await notifyGoing(
c,
await getGoingPlayerIds(c.env.DB, event.PlayerEventId),
NotificationType.PlayerEventUpdated,
payload,
event.PlayerEventId
)
}
/**
* Push a `PlayerEventDeleted` (82) to everyone who was Going to an event that was just
* deleted, so it leaves their screen. The frame is just the id — there is no event left
* to carry. `playerIds` must have been read before the delete took the RSVP rows with it.
*/
async function notifyEventDeleted(
c: Context<App>,
playerIds: number[],
playerEventId: number
): Promise<void> {
const payload = { PlayerEventId: playerEventId } satisfies PlayerEventIdPayload
await notifyGoing(c, playerIds, NotificationType.PlayerEventDeleted, payload, playerEventId)
}
/**
* Push a `PlayerEventResponseChanged` (83) to a player who just ANSWERED an event
* (`POST /api/playerevents/v1/respond`) — what puts the event on their screen without a
* refetch. This is the responder's own frame, carrying their own row.
*
* An INVITE does not send this. What an invitee gets is the invitation as a Message
* (see {@link pushEventInvitation}); their Pending row is not a response, and the
* client populates the event from this frame only once they have actually responded.
* Sending it on invite made the event appear as if the player had already answered.
*
* 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: the answer is already stored by the time this
* runs.
*/
async function notifyResponseChanged(
c: Context<App>,
event: PlayerEvent,
response: EventAttendeeRow
): Promise<void> {
const payload = {
PlayerEvent: { ...toEventNotification(event) },
PlayerEventResponse: { ...toEventResponse(response) },
} satisfies PlayerEventResponsePayload
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
response.player_id,
NotificationType.PlayerEventResponseChanged,
payload
)
} catch (err) {
logger.error('failed to push PlayerEventResponseChanged notification', {
playerEventId: event.PlayerEventId,
playerId: response.player_id,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Tell each player a bulk invite just added about it: the invitation as a Message of
* type 81 `PlayerEventInvitation`, stored and pushed as `MessageReceived` — see
* {@link pushEventInvitation}. That Message is the whole of what an invitee is sent; the
* `PlayerEventResponseChanged` frame is theirs only once they answer.
*
* 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. 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>,
inviterId: number,
event: PlayerEvent,
added: EventAttendeeRow[]
): Promise<void> {
for (const row of added) {
await pushEventInvitation(c, inviterId, row.player_id, event.PlayerEventId)
}
}
/**
* Store and push the invitation Message a bulk invite shows the invitee: a Message of
* type 81 `PlayerEventInvitation` from the inviter, delivered as `MessageReceived` and
* kept in the inbox (`GET /api/messages/v2/get`) so a player who was offline still finds
* it. The event is named twice — on the row's own `PlayerEventId` column, and as `Data`,
* the event id as a STRING (a Message's `Data` is always a string on the wire).
*
* The row is committed before the push; a hub failure is logged and swallowed, since the
* invite itself is already stored.
*/
async function pushEventInvitation(
c: Context<App>,
fromPlayerId: number,
toPlayerId: number,
playerEventId: number
): Promise<void> {
let message: StoredNotification
try {
message = await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: MessageType.PlayerEventInvitation,
Data: String(playerEventId),
PlayerEventId: playerEventId,
})
} catch (err) {
logger.error('failed to store PlayerEventInvitation message', {
playerEventId,
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toPlayerId,
NotificationType.MessageReceived,
{ ...message }
)
} catch (err) {
logger.error('failed to push PlayerEventInvitation MessageReceived notification', {
notificationId: message.Id,
playerEventId,
toPlayerId,
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 `["celebration"]` and anything newer `[{ Tag, Type }]`. 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) gets the pairs; that build, anything older, and a request
* with no readable token version all get the 2023 names. Builds are date-stamped, so they
* order as strings. `TagModifyResult.Tags` is a name list to every build — only the
* inline field moves.
*
* 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.
* Like it, each pushes a `PlayerEventUpdated` to everyone Going.
*/
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.
const tags = await getEventTags(c.env.DB, eventId)
await notifyEventUpdated(c, updated!, tags)
return c.json(await eventResult(c, updated!, tags))
}
}
/** 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 client’s BASE event — the v2 envelope’s 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 caller’s player events',
description:
'Events the player created and events they have RSVP’d 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 isn’t 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 caller’s 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 club’s event ' +
'list is small enough to serve in one page.',
parameters: [idParam('clubId', 'Club id')],
responses: { 200: json(PlayerEventsPage, 'The club’s 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, no envelope: the single-club form's `{ ContinuationToken, Events }` is
// the odd one out, and a room's shelf is small enough that there is nothing to page.
//
// The BASE event, not the stored record. The client reads this through the same generic
// helper and the same element type as the browse feed and the bulk read, so those three
// are shape-identical on its side — `toEventBase` is what keeps them identical here.
.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 room’s page — soonest first. A ' +
'bare array of the client’s BASE event (17 keys — no `State`, `ImageName` as `""` ' +
'rather than null, plus `BroadcastingRoomInstanceId`), the same projection the ' +
'browse feed and the bulk read serve: the client decodes all three through one ' +
'generic helper and one element type. `/searchlive` and the club shelves serve the ' +
'stored record instead.\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(PlayerEventBaseDto.array(), 'The room’s current and upcoming events'),
},
}),
async (c) => {
const events = await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
return c.json(events.map(toEventBase))
}
)
// 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 — the events behind a list of ids the client already holds. What the
// client actually calls is the POST, with the ids in a form body
// (`Ids=101&Ids=102&Ids=103`, or `Ids=13` for one); the GET below is the same read with
// the ids in the query, kept for hand-written calls.
//
// The BASE event, like the browse feed and the room shelf: one generic helper and one
// element type decode all three on the client, so this is "the feed, filtered to these
// ids" and must not drift into the stored-record shape the by-id read serves.
//
// Answers in the order 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 either way: no envelope, no `{ ContinuationToken, Events }`.
.post(
'/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, as a form body: `Ids` ' +
'repeated once per id (`Ids=101&Ids=102&Ids=103`), or one comma-separated `Ids=1,2,3`. ' +
'A bare array of the client’s BASE event — the same projection the browse feed and ' +
'the room shelf serve, this one filtered to the requested ids.\n\n' +
'Answers in the order the ids were asked for and skips ids with no event rather ' +
'than leaving a hole, so the result may be shorter than the request. No ids at all ' +
'is an empty array, not a 400.',
requestBody: form(BulkIdsRequest, 'The event ids to look up'),
responses: {
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
},
}),
async (c) => {
const events = await getEventsByIds(c.env.DB, await parseFormIds(c))
return c.json(events.map(toEventBase))
}
)
// The same read with the ids in the query (`?id=1&id=2`) — not what the client sends,
// but the shape stays identical to the POST's so the path can't answer two things.
.get(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id (query form)',
description:
'The same read as the POST on this path, with the ids in the query (`?id=1&id=2`) ' +
'rather than a form body — the client sends the POST. Identical response: a bare ' +
'array of the BASE event, in request order, skipping ids with no event.',
parameters: [intQuery('id', 'Repeatable event id')],
responses: {
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
},
}),
async (c) => {
const events = await getEventsByIds(c.env.DB, queryIds(c))
return c.json(events.map(toEventBase))
}
)
// 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 Can’t 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 event’s `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' +
'The caller is also pushed a `PlayerEventResponseChanged` frame carrying the event ' +
'and their new response row — this frame, not the invite, is what populates the ' +
'event on the client. An invitee answering Pending → Going gets it here like ' +
'anyone else.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400 — 3 ' +
'Pending is the server’s own mark for an unanswered invite, not an answer a ' +
'player can give; 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 result = await setEventResponse(c.env.DB, eventId, id, type)
if (result === null) return c.body(null, 404)
await notifyResponseChanged(c, result.event, result.response)
return c.json(await eventResult(c, result.event, 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 row’s ' +
'`reported_player_id` is the event’s 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 report’s 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 Pending (3) — an open
// invitation, not an acceptance.
.post(
'/api/playerevents/v1/bulkInvite',
describeRoute({
tags: ['Events'],
summary: 'Invite players to an event',
description:
'Adds the invited players to the event as Pending (`Type` 3) — the same ' +
'`event_attendee` rows an RSVP writes, so an invited player shows up in ' +
'`…/responses` with an open invitation. Pending is not Going: the invitee does not ' +
'count toward `AttendeeCount` until they answer through `…/respond`. (Inviting as ' +
'Going made the invitation look already accepted on the client.)\n\n' +
'An invite never overwrites an answer: a player who already responded keeps what ' +
'they said, so inviting someone who declined does not reset them to Pending, and ' +
're-inviting is a no-op. The caller is skipped (they are already on the list), as ' +
'are duplicate ids.\n\n' +
'Each newly invited player is sent the invitation as a Message of type 81 ' +
'`PlayerEventInvitation` from the caller — stored in their inbox ' +
'(`GET /api/messages/v2/get`) and pushed as `MessageReceived`, with the event id ' +
'on `PlayerEventId` and, as a string, in `Data`. That is all an invitee gets: no ' +
'`PlayerEventResponseChanged` is pushed on invite — that frame is what `…/respond` ' +
'sends once they answer, and is what populates the event on their client. ' +
'Players whose existing answer was left alone get nothing.\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 (its attendee count is unchanged by an invite)'),
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, id, 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 can’t 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' +
'Everyone who was GOING to the event (the creator included) is pushed a ' +
'`PlayerEventDeleted` frame naming the event id, so it leaves their screen; ' +
'interested, declined and still-Pending invitees are not.\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 event’s 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)
// Who to tell has to be read BEFORE the delete: it takes the RSVP rows with it.
const going = await getGoingPlayerIds(c.env.DB, eventId)
await deleteEvent(c.env.DB, eventId)
await notifyEventDeleted(c, going, 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 event’s 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 can’t 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 doesn’t ' +
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.\n\n' +
'Everyone who is GOING to the event (the creator included) is pushed a ' +
'`PlayerEventUpdated` frame carrying the edited event; interested, declined and ' +
'still-Pending invitees are not. The single-field edits push the same frame.\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 event’s 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.
const tags = await getEventTags(c.env.DB, eventId)
await notifyEventUpdated(c, updated!, tags)
return c.json(await eventResult(c, updated!, tags))
}
)
// ---- 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 event’s 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 event’s 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 event’s accessibility',
description:
'Sets an event’s visibility. The client sends the `RoomAccessibility` NAME here ' +
'(`accessibility=Unlisted`), the way it does on the subroom route in `rooms` — not ' +
'the ordinal the event’s 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 event’s 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 event’s tags',
description:
'Replaces an event’s 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 event’s 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 event’s description',
description:
'Rewrites an event’s 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 can’t be a no-op.\n\n' +
'Capped at 512 characters, the stored length, and refused rather than truncated: ' +
'silently cutting a player’s 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 event’s 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 event’s 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 event’s 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 creator’s Going row.',
parameters: [idParam('eventId', 'Event id')],
responses: { 200: json(PlayerEventResponseDto.array(), 'The event’s 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 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(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) })
}
)