mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -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
|
||||
|
||||
Reference in New Issue
Block a user