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:
88
frontend/src/lib/timeline/eventFormServer.ts
Normal file
88
frontend/src/lib/timeline/eventFormServer.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user