import { m } from '$lib/paraglide/messages.js'; import { createApiClient } from '$lib/shared/api.server'; import type { components } from '$lib/generated/api'; import type { DatePrecision } from '$lib/shared/utils/documentDate'; import type { PersonOption } from '$lib/person/personOption'; import type { DocumentOption } from '$lib/document/documentTypeahead'; type TimelineEventRequest = components['schemas']['TimelineEventRequest']; type ApiClient = ReturnType; // 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; // Whitelist of accepted precision tokens — mirrors the DatePrecision union. Any // other submitted value falls back to DAY rather than flowing untrusted into the // request body (the backend enum is the hard gate; this keeps the two symmetric // with the `type` narrowing below). const VALID_PRECISIONS: readonly DatePrecision[] = [ 'DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN' ]; /** * 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 rawPrecision = formData.get('precision')?.toString() as DatePrecision | undefined; const precision: DatePrecision = rawPrecision && VALID_PRECISIONS.includes(rawPrecision) ? rawPrecision : '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() ?? '' }; } /** * Returns both failing required-field errors (title + date) simultaneously, or * null when the form is valid. The route owns the `fail(400)` so it can enrich * the payload with the preserved field values and rehydrated picker selections. */ export function validateEventForm( parsed: ParsedEventForm ): { titleError: string; dateError: string } | null { 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 { titleError, dateError }; } /** The entered field values echoed back in every `fail(...)` so the form re-renders without loss. */ export function preservedFormFields(parsed: ParsedEventForm) { return { title: parsed.title, description: parsed.description, type: parsed.type, personIds: parsed.personIds, documentIds: parsed.documentIds }; } /** * Re-fetches the selected persons/documents by id so a `fail(400)` can re-render * the pickers with full chip labels — the form only resubmits bare ids, which * cannot rebuild a chip on their own (Decision 6 / REQ-010). Non-ok lookups are * swallowed: a since-deleted id silently drops from the picker rather than * leaking existence, mirroring the prefill path in the new-route load. */ export async function lookupSelections( api: ApiClient, personIds: string[], documentIds: string[] ): Promise<{ persons: PersonOption[]; documents: DocumentOption[] }> { const [personResults, documentResults] = await Promise.all([ Promise.all(personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))), Promise.all( documentIds.map((id) => api.GET('/api/documents/{id}', { params: { path: { id } } })) ) ]); return { persons: personResults.filter((r) => r.response.ok && r.data).map((r) => r.data!), documents: documentResults .filter((r) => r.response.ok && r.data) .map((r) => ({ id: r.data!.id, title: r.data!.title, documentDate: r.data!.documentDate, metaDatePrecision: r.data!.metaDatePrecision, metaDateEnd: r.data!.metaDateEnd })) }; } /** 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; }