[events] prevent events longer than 24h

This commit is contained in:
Devin Zuczek
2026-08-19 17:36:01 -04:00
parent 6add3437cf
commit f2f3c7badc
5 changed files with 681 additions and 28 deletions
+280 -5
View File
@@ -1,6 +1,7 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { Accessibility } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
@@ -21,6 +22,8 @@ import {
inviteToEvent,
isEventResponseType,
parseEventBody,
parseEventTags,
parseEventTime,
searchEvents,
setEventResponse,
toEventBase,
@@ -32,15 +35,19 @@ import {
import { authedId, queryIds, unauthorized } from '../http'
import {
AUTHED,
form,
idParam,
intQuery,
json,
jsonBody,
pageParams,
PlayerEventAccessibilityRequest,
PlayerEventBaseDto,
PlayerEventBulkInviteRequest,
PlayerEventDescriptionRequest,
PlayerEventDetailsDto,
PlayerEventDto,
PlayerEventNameRequest,
PlayerEventReportRequest,
PlayerEventRequest,
PlayerEventRespondRequest,
@@ -48,6 +55,8 @@ import {
PlayerEventResultDto,
PlayerEventsAll,
PlayerEventsPage,
PlayerEventTagsRequest,
PlayerEventTimeRequest,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
@@ -58,7 +67,7 @@ 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, EventTag, PlayerEvent } from '../events-db'
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'
@@ -133,6 +142,66 @@ async function notifyInvited(
}
}
/**
* 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(toEventResult(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.
*
@@ -554,6 +623,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
'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.',
@@ -561,7 +634,11 @@ export const eventRoutes = new Hono<App>({ strict: false })
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
responses: {
200: json(PlayerEventResultDto, 'The created event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
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,
},
}),
@@ -678,13 +755,20 @@ export const eventRoutes = new Hono<App>({ strict: false })
'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.',
'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 (empty body)' },
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)' },
@@ -700,13 +784,204 @@ export const eventRoutes = new Hono<App>({ strict: false })
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
if (eventInputRejection(input) !== null) return c.body(null, 400)
// 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(toEventResult(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',