A RANGE event with a blank end date passed validateEventForm and reached the backend, which 400s with a generic INVALID_DATE_RANGE mapped to "end must not be before start" — wrong for a missing end date, and shown only as a top-of-form alert. Validate it before the API call and surface a dedicated event_editor_end_date_required message on the end-date field via a new DatePrecisionField endDateError prop (defaults '', so the document form is unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
6.2 KiB
TypeScript
155 lines
6.2 KiB
TypeScript
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<typeof createApiClient>;
|
|
|
|
// 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 the failing required-field errors (title + date + RANGE end-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; endDateError: string } | null {
|
|
const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : '';
|
|
const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : '';
|
|
// A RANGE event requires an end date. Catch it here so it never reaches the
|
|
// backend, which rejects with a generic INVALID_DATE_RANGE mapped to the wrong
|
|
// "end before start" message and no field-level cue.
|
|
const endDateError =
|
|
parsed.precision === 'RANGE' && !parsed.eventDateEnd ? m.event_editor_end_date_required() : '';
|
|
if (!titleError && !dateError && !endDateError) return null;
|
|
return { titleError, dateError, endDateError };
|
|
}
|
|
|
|
/** 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,
|
|
// The When-section too, so a no-JS full reload re-seeds the date controls
|
|
// instead of dropping the curator's date/precision/end-date.
|
|
eventDate: parsed.eventDate,
|
|
precision: parsed.precision,
|
|
eventDateEnd: parsed.eventDateEnd,
|
|
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;
|
|
}
|