(wip) events

This commit is contained in:
Devin Zuczek
2026-08-04 18:44:11 -04:00
parent 65611c15d8
commit 10eb89ac12
7 changed files with 1284 additions and 123 deletions
+345
View File
@@ -0,0 +1,345 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { logger } from '@repo/hono-helpers'
// 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,
getEventById,
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getLiveEvents,
parseEventBody,
searchEvents,
toEventNotification,
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, queryIds, unauthorized } from '../http'
import {
AUTHED,
idParam,
intQuery,
json,
jsonBody,
pageParams,
PlayerEventDto,
PlayerEventRequest,
PlayerEventResultDto,
PlayerEventsAll,
PlayerEventsPage,
stringQuery,
TagFilters,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import type { Context } from 'hono'
import type { App } from '../context'
import type { 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): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
event.CreatorPlayerId,
NotificationType.PlayerEventCreated,
{ ...toEventNotification(event) }
)
} catch (err) {
logger.error('failed to push PlayerEventCreated notification', {
playerEventId: event.PlayerEventId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 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 })
.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. `Responses` is always empty — nothing ' +
'records an RSVP yet.',
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 })
}
)
// 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. `query` is matched ' +
'case-insensitively against the event name and description, term by term; an empty ' +
'query browses everything upcoming. 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 text; every term must match the name or description'),
...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)))
)
// 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' +
'`AttendeeCount` starts at 1 (the creator attends their own event) and `State` at ' +
'0. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT the bare ' +
'event the read endpoints serve.\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'),
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 event = await createEvent(c.env.DB, id, parseEventBody(body))
await notifyEventCreated(c, event)
return c.json(toEventResult(event))
}
)
// 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.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
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 updated = await updateEvent(c.env.DB, eventId, parseEventBody(body))
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(toEventResult(updated!))
}
)
// 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.',
parameters: [idParam('eventId', 'Event id')],
responses: {
200: json(PlayerEventDto, 'The event'),
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10))
return event === null ? c.body(null, 404) : c.json(event)
}
)