feat(timeline): add /zeitstrahl/events/new curator create route

Server load gates on hasWriteAll with a null-user guard first (403 error page,
the persons/new idiom — not a redirect); prefills ?personId/?documentId via
Promise.all, swallowing 404/403 so unknown ids never leak. The save action
parses the form, surfaces title+date required errors simultaneously via
fail(400) preserving picker arrays, builds a TimelineEventRequest (eventDateEnd
explicit null off RANGE), POSTs, maps API/409 errors via getErrorMessage without
redirecting, and redirects to a UUID-validated nav target (CWE-601). Shared
parse/validate/build/nav helpers live in eventFormServer.ts for reuse by the
edit route. 11/11 server specs green.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 22:49:05 +02:00
parent 15ff6db1d3
commit 59d78150b3
4 changed files with 388 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
import { fail } from '@sveltejs/kit';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type TimelineEventRequest = components['schemas']['TimelineEventRequest'];
// Prevents open redirect: validate before constructing /persons/{id}. See OWASP CWE-601.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Resolves the context-aware post-save / post-delete redirect target. Returns
* the originating person page only when `originPersonIdRaw` is a strict UUID;
* otherwise falls back to the timeline (open-redirect guard).
*/
export function resolveNavTarget(originPersonIdRaw: string): string {
return UUID_RE.test(originPersonIdRaw) ? `/persons/${originPersonIdRaw}` : '/zeitstrahl';
}
export interface ParsedEventForm {
title: string;
type: 'PERSONAL' | 'HISTORICAL';
eventDate: string;
precision: DatePrecision;
eventDateEnd: string | null;
description: string;
personIds: string[];
documentIds: string[];
originPersonId: string;
}
/** Reads the curator event form fields out of submitted FormData. */
export function parseEventForm(formData: FormData): ParsedEventForm {
const rawType = formData.get('type')?.toString();
const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL';
const precision = (formData.get('metaDatePrecision')?.toString() as DatePrecision) || 'DAY';
const endRaw = formData.get('eventDateEnd')?.toString().trim() ?? '';
// Off-RANGE submits an empty string → null so a stale end-date never persists.
const eventDateEnd = precision === 'RANGE' && endRaw ? endRaw : null;
return {
title: formData.get('title')?.toString().trim() ?? '',
type,
eventDate: formData.get('eventDate')?.toString().trim() ?? '',
precision,
eventDateEnd,
description: formData.get('description')?.toString().trim() ?? '',
personIds: formData.getAll('personIds').map((v) => v.toString()),
documentIds: formData.getAll('documentIds').map((v) => v.toString()),
originPersonId: formData.get('originPersonId')?.toString() ?? ''
};
}
/**
* Surfaces all failing required-field errors simultaneously (title + date) via a
* fail(400) payload that also preserves every entered value — including the
* picker arrays — so the form re-renders without losing state. Returns null when
* the form is valid.
*/
export function validateEventForm(parsed: ParsedEventForm) {
const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : '';
const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : '';
if (!titleError && !dateError) return null;
return fail(400, {
titleError,
dateError,
title: parsed.title,
description: parsed.description,
type: parsed.type,
personIds: parsed.personIds,
documentIds: parsed.documentIds
});
}
/** Builds the TimelineEventRequest write body from parsed form fields. */
export function toEventRequest(parsed: ParsedEventForm, version?: number): TimelineEventRequest {
return {
title: parsed.title,
type: parsed.type,
eventDate: parsed.eventDate,
precision: parsed.precision,
eventDateEnd: parsed.eventDateEnd,
...(parsed.description ? { description: parsed.description } : {}),
...(parsed.personIds.length ? { personIds: parsed.personIds } : {}),
...(parsed.documentIds.length ? { documentIds: parsed.documentIds } : {}),
...(version !== undefined ? { version } : {})
} as TimelineEventRequest;
}