[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
+76 -21
View File
@@ -17,7 +17,12 @@
* relational table rather than a JSON blob.
*/
import { glyphLength, MAX_EVENT_DESCRIPTION_LENGTH, MAX_EVENT_NAME_LENGTH } from '@repo/domain'
import {
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_DURATION_MS,
MAX_EVENT_NAME_LENGTH,
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
@@ -315,6 +320,21 @@ function eventTime(ms: number): string {
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
/**
* Normalize one posted timestamp into the stored form, or undefined when it isn't a
* usable date.
*
* Exported for the single-field time edit (`PUT …/v2/{id}/time`), which has to tell an
* ABSENT bound — leave the stored one alone — from an unusable one, which it refuses.
* {@link parseEventBody} collapses the two, since a create/update posting rubbish for a
* time is better off defaulting than failing.
*/
export function parseEventTime(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined
const parsed = Date.parse(raw)
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
}
/** An event's tags, alphabetical so a list read is stable. */
export async function getEventTags(db: D1Database, eventId: number): Promise<EventTag[]> {
const { results } = await db
@@ -384,26 +404,46 @@ function asInt(value: unknown): number | undefined {
}
/**
* Parse a posted event body into an {@link EventInput}.
* The window a write would end up storing, resolved the way {@link createEvent} and
* {@link updateEvent} resolve it: a bound the body carries wins, otherwise the stored one
* (an edit), otherwise the create defaults — now, and an hour later.
*
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
* the client posts the same envelope it reads back, and both forms are in circulation.
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
* rather than stored.
* Exists so the duration rule below and the writes themselves can't drift apart on what
* "the event's window" means for a body that moves only one bound.
*/
function resolvedWindow(
input: EventInput,
existing?: PlayerEvent,
now = Date.now()
): { start: number; end: number } {
const start = Date.parse(input.startTime ?? existing?.StartTime ?? eventTime(now))
const stored = input.endTime ?? existing?.EndTime
return { start, end: stored === undefined ? start + DEFAULT_DURATION_MS : Date.parse(stored) }
}
/**
* Why a parsed event body can't be stored, or `null` when it's fine.
*
* Length only. An event name is a title, not an identifier — "Building a Better Room
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
* names carry would be wrong here. Absent fields are skipped: an update posts only what
* it changes, and create defaults a missing name rather than refusing it.
* Two rules: the stored lengths, and the window.
*
* The name is measured AFTER trimming, matching what create/update actually store.
* Lengths are a cap, not a charset — an event name is a title, not an identifier
* ("Building a Better Room Using Trigonometry" is a real one), so the alphanumeric rule
* the account and room names carry would be wrong here. Absent fields are skipped: an
* update posts only what it changes, and create defaults a missing name rather than
* refusing it. The name is measured AFTER trimming, matching what the writes store.
*
* The window is checked on what the write RESOLVES to rather than on the fields the body
* carries, which is why `existing` is passed for an edit: moving the start alone still
* has to leave a window that ends after it and runs no longer than
* {@link MAX_EVENT_DURATION_MS}. A create resolves against the same defaults
* {@link createEvent} applies, so a body naming neither bound — or only a start — can
* never fail this.
*
* A backwards window is refused here too. It isn't a duration rule as such, but it's the
* hole in one: `end - start` on a window running a month backwards is negative, which
* would sail past a "no longer than a day" check.
*/
export function eventInputRejection(input: EventInput): string | null {
export function eventInputRejection(input: EventInput, existing?: PlayerEvent): string | null {
const name = input.name?.trim()
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
@@ -414,6 +454,16 @@ export function eventInputRejection(input: EventInput): string | null {
) {
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
}
const { start, end } = resolvedWindow(input, existing)
// Unparseable can't happen from `parseEventBody` (it drops what it can't read) but can
// from a stored blob edited by hand; skip the rule rather than refusing an edit that
// says nothing about the times.
if (Number.isNaN(start) || Number.isNaN(end)) return null
if (end < start) return 'An event cannot end before it starts.'
if (end - start > MAX_EVENT_DURATION_MS) {
return `An event can run for at most ${MAX_EVENT_DURATION_MS / (60 * 60 * 1000)} hours.`
}
return null
}
@@ -427,7 +477,7 @@ export function eventInputRejection(input: EventInput): string | null {
* are lowercased (the search matches them lowercased, and `#Workshops` and `#workshops`
* are the same chip), a leading `#` is stripped, and blanks/duplicates are dropped.
*/
function parseEventTags(raw: unknown): EventTag[] | undefined {
export function parseEventTags(raw: unknown): EventTag[] | undefined {
if (!Array.isArray(raw)) return undefined
const byTag = new Map<string, EventTag>()
for (const entry of raw) {
@@ -444,6 +494,16 @@ function parseEventTags(raw: unknown): EventTag[] | undefined {
return [...byTag.values()]
}
/**
* Parse a posted event body into an {@link EventInput}.
*
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
* the client posts the same envelope it reads back, and both forms are in circulation.
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
* rather than stored.
*/
export function parseEventBody(body: unknown): EventInput {
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
const nested = outer.PlayerEvent
@@ -458,12 +518,7 @@ export function parseEventBody(body: unknown): EventInput {
if (!has(key)) return undefined
return obj[key] === null ? null : asInt(obj[key])
}
const time = (key: string): string | undefined => {
const raw = obj[key]
if (typeof raw !== 'string') return undefined
const parsed = Date.parse(raw)
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
}
const time = (key: string): string | undefined => parseEventTime(obj[key])
const bool = (key: string): boolean | undefined => {
const raw = obj[key]
if (typeof raw === 'boolean') return raw
+47 -1
View File
@@ -610,7 +610,9 @@ export const PlayerEventDto = z.object({
Name: z.string(),
Description: z.string(),
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
EndTime: z
.string()
.describe('ISO 8601 UTC, seconds precision; at most 24 hours after `StartTime`'),
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
State: z.int().describe('0 = scheduled'),
Accessibility: z.int(),
@@ -691,6 +693,50 @@ export const PlayerEventRequest = PlayerEventDto.partial().extend({
.describe('The events fields, if nested rather than posted at the top level'),
})
/**
* `PUT /api/playerevents/v2/{eventId}/time` form body — the event's window, moved. Both
* bounds are optional; an absent one keeps its stored value, so the start can be nudged
* without restating the end. The RESOLVED window must end after it starts and run no
* longer than 24 hours.
*/
export const PlayerEventTimeRequest = z.object({
startTime: z
.string()
.optional()
.describe('New start, any parseable ISO 8601 — the client sends .NET tick precision'),
endTime: z.string().optional().describe('New end, same form'),
})
/**
* `PUT /api/playerevents/v2/{eventId}/accessibility` form body. The client sends the
* `RoomAccessibility` NAME, as it does on the subroom route in `rooms`; the ordinal is
* accepted too.
*/
export const PlayerEventAccessibilityRequest = z.object({
accessibility: z
.string()
.describe(
'`Private`, `Public`, `Unlisted`, `Dev_only` or `Dev_Unlisted` (case-insensitive) — ' +
'or its ordinal 04'
),
})
/** `PUT /api/playerevents/v2/{eventId}/name` form body. */
export const PlayerEventNameRequest = z.object({
name: z.string().describe('The new title; blank is refused — an event always has a name'),
})
/** `PUT /api/playerevents/v2/{eventId}/description` form body. */
export const PlayerEventDescriptionRequest = z.object({
description: z.string().optional().describe('The new blurb; absent clears it'),
})
/**
* `PUT /api/playerevents/v2/{eventId}/tags` body — a BARE JSON ARRAY of tag names
* (`["tag1","class"]`), not an object. The whole set the event should carry.
*/
export const PlayerEventTagsRequest = z.array(z.string()).describe('The events whole tag set')
/**
* `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as
* the guest list serves it.
+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',
+266 -1
View File
@@ -48,7 +48,7 @@ import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../war
import type { SavedImage } from '@repo/domain'
import type { Env } from '../../context'
import type { PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
declare module 'cloudflare:test' {
@@ -3486,6 +3486,8 @@ describe('mutual friends', () => {
describe('player events', () => {
const HOUR = 60 * 60 * 1000
/** The longest window a write may store — an event lasts at most a day. */
const DAY = 24 * HOUR
/**
* Seconds precision, no milliseconds — the form the client sends and reads back.
*
@@ -3685,6 +3687,66 @@ describe('player events', () => {
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
})
test('the event writes cap the window at 24 hours', async () => {
const window = (StartTime: string, EndTime: string) =>
post('/api/playerevents/v2', { Name: 'Windowed', RoomId: 3, StartTime, EndTime })
// A day exactly is allowed — "at most one day", not "under one day".
expect((await window(at(0), at(DAY))).status).toBe(200)
// A second past it is not.
expect((await window(at(0), at(DAY + 1000))).status).toBe(400)
expect((await window(at(0), at(30 * DAY))).status).toBe(400)
// A missing end defaults to an hour after the start, so it can never fail…
expect(
(await post('/api/playerevents/v2', { Name: 'Open ended', RoomId: 3, StartTime: at(DAY) }))
.status
).toBe(200)
// …but an end alone is measured from now, which can.
expect(
(await post('/api/playerevents/v2', { Name: 'Far end', RoomId: 3, EndTime: at(2 * DAY) }))
.status
).toBe(400)
expect(
(await post('/api/playerevents/v2', { Name: 'Near end', RoomId: 3, EndTime: at(HOUR) }))
.status
).toBe(200)
// A body naming neither is defaulted, as before.
expect((await post('/api/playerevents/v2', { Name: 'Untimed', RoomId: 3 })).status).toBe(200)
// A backwards window is refused too — `end - start` on one running a month
// backwards is negative, which would sail past a "no longer than a day" check.
expect((await window(at(3 * HOUR), at(HOUR))).status).toBe(400)
})
test('the 24-hour cap is checked on the window a write RESOLVES to', async () => {
const event = await create({
RoomId: 3,
Name: 'Movable',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}`
// Moving one bound is measured against the STORED other one, not against a default:
// a start dragged two days back leaves a window far longer than a day.
expect((await post(path, { StartTime: at(-2 * DAY) })).status).toBe(400)
expect((await post(path, { EndTime: at(2 * DAY) })).status).toBe(400)
// Both bounds moved together stay inside the cap, so this is fine.
expect((await post(path, { StartTime: at(2 * DAY), EndTime: at(2 * DAY + HOUR) })).status).toBe(
200
)
// An edit that says nothing about the times is unaffected.
expect((await post(path, { Name: 'Still Movable' })).status).toBe(200)
// …and a refusal left the event where it was.
const stored = (await (
await get(`/api/playerevents/v1/${event.PlayerEventId}`)
).json()) as PlayerEvent
expect(stored.StartTime).toBe(at(2 * DAY))
expect(stored.EndTime).toBe(at(2 * DAY + HOUR))
})
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
const res = await post('/api/playerevents/v2', {
Name: 'Enveloped',
@@ -4416,6 +4478,204 @@ describe('player events', () => {
expect(updated.CreatorPlayerId).toBe(42)
expect(updated.AttendeeCount).toBe(1)
})
// ---- Single-field edits (PUT …/v2/:eventId/:field) -----------------------
/** A form-encoded single-field edit — the encoding the client sends on these. */
const putForm = async (
path: string,
fields: Record<string, string>,
sub: string | null = '42'
): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: {
...(sub === null ? {} : await bearer(sub)),
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
const putJson = async (path: string, body: unknown, sub = '42'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
body: JSON.stringify(body),
})
/** The envelope's event out of a 200 from one of the edits. */
const edited = async (res: Response): Promise<PlayerEventEnvelope> => {
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
return body.PlayerEvent
}
test('PUT /api/playerevents/v2/:eventId/time moves either bound independently', async () => {
const event = await create({
RoomId: 5,
Name: 'Reschedulable',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}/time`
// The client sends .NET tick precision; it is stored trimmed to seconds.
const moved = await edited(
await putForm(path, {
startTime: at(7 * HOUR).replace('Z', '.0000000Z'),
endTime: at(9 * HOUR).replace('Z', '.0000000Z'),
})
)
expect(moved).toEqual({ ...event, StartTime: at(7 * HOUR), EndTime: at(9 * HOUR) })
// One bound alone keeps the other.
const nudged = await edited(await putForm(path, { endTime: at(10 * HOUR) }))
expect(nudged.StartTime).toBe(at(7 * HOUR))
expect(nudged.EndTime).toBe(at(10 * HOUR))
// A day exactly is allowed — the cap is "at most a day", not "under a day".
const full = await edited(await putForm(path, { endTime: at(7 * HOUR + DAY) }))
expect(full.EndTime).toBe(at(7 * HOUR + DAY))
})
test('PUT /api/playerevents/v2/:eventId/time refuses rubbish and a backwards window', async () => {
const event = await create({
RoomId: 5,
Name: 'Fixed Window',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}/time`
// Present but unparseable is refused rather than dropped: a reschedule that
// silently did nothing is worse than a refusal.
expect((await putForm(path, { startTime: 'tomorrowish' })).status).toBe(400)
// An end before the start, checked against the STORED bound when only one is sent.
expect((await putForm(path, { endTime: at(4 * HOUR) })).status).toBe(400)
expect((await putForm(path, { startTime: at(9 * HOUR), endTime: at(8 * HOUR) })).status).toBe(
400
)
// And a window longer than a day, resolved the same way.
expect((await putForm(path, { endTime: at(5 * HOUR + DAY + 1000) })).status).toBe(400)
expect((await putForm(path, { startTime: at(-DAY) })).status).toBe(400)
expect(
(await putForm(path, { startTime: at(2 * DAY), endTime: at(2 * DAY + DAY + 1000) })).status
).toBe(400)
// An empty body changes nothing, and is not an error.
expect((await edited(await putForm(path, {}))).StartTime).toBe(at(5 * HOUR))
// …and nothing stuck.
const stored = (await (
await get(`/api/playerevents/v1/${event.PlayerEventId}`)
).json()) as PlayerEvent
expect(stored.EndTime).toBe(at(6 * HOUR))
})
test('PUT /api/playerevents/v2/:eventId/accessibility takes the enum name', async () => {
const event = await create({ RoomId: 5, Name: 'Visible', Accessibility: 1 })
const path = `/api/playerevents/v2/${event.PlayerEventId}/accessibility`
// The NAME is what the client sends here.
expect((await edited(await putForm(path, { accessibility: 'Unlisted' }))).Accessibility).toBe(2)
// Case-insensitively…
expect((await edited(await putForm(path, { accessibility: 'private' }))).Accessibility).toBe(0)
// …and the ordinal works too.
expect((await edited(await putForm(path, { accessibility: '4' }))).Accessibility).toBe(4)
// Anything else is refused rather than stored verbatim — guessing a visibility
// wrong is what shows a private event to everyone.
expect((await putForm(path, { accessibility: 'Secret' })).status).toBe(400)
expect((await putForm(path, { accessibility: '9' })).status).toBe(400)
expect((await putForm(path, {})).status).toBe(400)
expect(
((await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()) as PlayerEvent)
.Accessibility
).toBe(4)
})
test('PUT /api/playerevents/v2/:eventId/tags replaces the whole set from a bare array', async () => {
const event = await create({ RoomId: 5, Name: 'Taggable', Tags: ['meetup'] })
const path = `/api/playerevents/v2/${event.PlayerEventId}/tags`
// A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes.
const tagged = await edited(await putJson(path, ['tag1', '#Class']))
expect(tagged.Tags).toEqual(['class', 'tag1'])
// The envelope's TagModifyResult reports the same set the client redraws chips from.
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })
// `[]` clears them; a non-array body is refused.
expect((await edited(await putJson(path, []))).Tags).toEqual([])
expect((await putJson(path, { Tags: ['nope'] })).status).toBe(400)
expect(
(
(await (
await get(`/api/playerevents/v1/${event.PlayerEventId}?includeDetails=True`)
).json()) as PlayerEvent & { tags: EventTag[] }
).tags
).toEqual([])
})
test('PUT /api/playerevents/v2/:eventId/description rewrites the blurb; absent clears it', async () => {
const event = await create({ RoomId: 5, Name: 'Described', Description: 'The old blurb' })
const path = `/api/playerevents/v2/${event.PlayerEventId}/description`
const written = await edited(
await putForm(path, { description: 'fthe description of said event' })
)
expect(written).toEqual({ ...event, Description: 'fthe description of said event' })
// An emptied text box sends no field at all, which clears it.
expect((await edited(await putForm(path, {}))).Description).toBe('')
// Capped at the stored length, and refused rather than truncated.
expect((await putForm(path, { description: 'd'.repeat(513) })).status).toBe(400)
expect((await putForm(path, { description: 'd'.repeat(512) })).status).toBe(200)
})
test('PUT /api/playerevents/v2/:eventId/name retitles, refusing a blank or overlong one', async () => {
const event = await create({ RoomId: 5, Name: 'Before' })
const path = `/api/playerevents/v2/${event.PlayerEventId}/name`
const renamed = await edited(
await putForm(path, { name: 'an event in the future I should be able to editx' })
)
expect(renamed).toEqual({
...event,
Name: 'an event in the future I should be able to editx',
})
// Stored trimmed.
expect((await edited(await putForm(path, { name: ' Padded ' }))).Name).toBe('Padded')
// A blank name renders as a blank row, and the whole-event update reads one as
// "leave it alone" — so it is refused outright here.
expect((await putForm(path, { name: ' ' })).status).toBe(400)
expect((await putForm(path, {})).status).toBe(400)
expect((await putForm(path, { name: 'n'.repeat(65) })).status).toBe(400)
expect((await putForm(path, { name: 'n'.repeat(64) })).status).toBe(200)
})
test('the single-field edits are creator-only, like the whole-event update', async () => {
const event = await create({ RoomId: 5, Name: 'Guarded' })
const id = event.PlayerEventId
for (const [field, fields] of [
['time', { startTime: at(8 * HOUR) }],
['accessibility', { accessibility: 'Public' }],
['description', { description: 'nope' }],
['name', { name: 'Hijacked' }],
] as Array<[string, Record<string, string>]>) {
expect((await putForm(`/api/playerevents/v2/${id}/${field}`, fields, null)).status).toBe(401)
// 43 didn't create it.
expect((await putForm(`/api/playerevents/v2/${id}/${field}`, fields, '43')).status).toBe(403)
expect((await putForm(`/api/playerevents/v2/999999/${field}`, fields)).status).toBe(404)
}
// The tags edit takes JSON rather than a form, but is gated the same way.
expect((await putJson(`/api/playerevents/v2/${id}/tags`, ['nope'], '43')).status).toBe(403)
expect((await putJson('/api/playerevents/v2/999999/tags', ['nope'])).status).toBe(404)
// Nothing moved.
expect(await (await get(`/api/playerevents/v1/${id}`)).json()).toEqual(asRecord(event, null))
})
})
describe('openapi', () => {
@@ -4577,6 +4837,11 @@ describe('openapi', () => {
'POST /api/sanitize/v1/isPure',
'POST /api/v1/progression/bulk',
'POST /statsigUserProperties',
'PUT /api/playerevents/v2/{eventId}/accessibility',
'PUT /api/playerevents/v2/{eventId}/description',
'PUT /api/playerevents/v2/{eventId}/name',
'PUT /api/playerevents/v2/{eventId}/tags',
'PUT /api/playerevents/v2/{eventId}/time',
'PUT /api/players/v1/playerPhotoTaggingSetting',
'PUT /outfits/me',
])
+12
View File
@@ -38,6 +38,18 @@ export const MAX_CLUB_DESCRIPTION_LENGTH = 512
export const MAX_EVENT_NAME_LENGTH = 64
export const MAX_EVENT_DESCRIPTION_LENGTH = 512
/**
* The longest a player event may run a day, inclusive, so a full 24-hour event is
* allowed and anything past it is refused. An event is a scheduled get-together in one
* room, not a season: a window of weeks would sit in the browse feed's "upcoming" and
* "happening now" rows indefinitely, crowding out everything real.
*
* Applied to the window a write RESOLVES to, not to the fields it carries an edit that
* moves one bound is checked against the stored other one. See `eventInputRejection` in
* the api worker.
*/
export const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1000
/**
* Invention limits. A name is a title a player types into the invention-save box and
* reads back in a browse tile, so it allows the punctuation a title needs but