mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[events] prevent events longer than 24h
This commit is contained in:
+76
-21
@@ -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
@@ -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 event’s 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 0–4'
|
||||
),
|
||||
})
|
||||
|
||||
/** `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 event’s whole tag set')
|
||||
|
||||
/**
|
||||
* `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as
|
||||
* the guest list serves it.
|
||||
|
||||
@@ -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 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.',
|
||||
'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 event’s 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 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',
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user