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;
}

View File

@@ -0,0 +1,81 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
import {
parseEventForm,
validateEventForm,
toEventRequest,
resolveNavTarget
} from '$lib/timeline/eventFormServer';
import type { PersonOption } from '$lib/person/personOption';
import type { DocumentOption } from '$lib/document/documentTypeahead';
export async function load({
locals,
url,
fetch
}: {
locals: App.Locals;
url: URL;
fetch: typeof globalThis.fetch;
}) {
// Null-user guard first — avoids a TypeError on locals.user.groups for an
// unauthenticated request that reaches the route.
if (!locals.user) throw error(403, 'Forbidden');
// WRITE_ALL check mirrors Permission.WRITE_ALL — server-side gate; frontend
// canWrite flag is for hiding entry-point buttons only.
if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
const api = createApiClient(fetch);
const personId = url.searchParams.get('personId');
const documentId = url.searchParams.get('documentId');
const [personResult, documentResult] = await Promise.all([
personId ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) : null,
documentId ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) : null
]);
// Silently ignore 404/403 on prefill lookups to avoid leaking entity existence.
const initialPersons: PersonOption[] =
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
const initialDocuments: DocumentOption[] =
documentResult && documentResult.response.ok && documentResult.data
? [
{
id: documentResult.data.id,
title: documentResult.data.title,
documentDate: documentResult.data.documentDate,
metaDatePrecision: documentResult.data.metaDatePrecision,
metaDateEnd: documentResult.data.metaDateEnd
}
]
: [];
return { initialPersons, initialDocuments, originPersonId: personId ?? '' };
}
export const actions = {
save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
const parsed = parseEventForm(await request.formData());
const invalid = validateEventForm(parsed);
if (invalid) return invalid;
const api = createApiClient(fetch);
const result = await api.POST('/api/timeline/events', { body: toEventRequest(parsed) });
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error)),
title: parsed.title,
description: parsed.description,
type: parsed.type,
personIds: parsed.personIds,
documentIds: parsed.documentIds
});
}
throw redirect(303, resolveNavTarget(parsed.originPersonId));
}
};

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import EventForm from '$lib/timeline/EventForm.svelte';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<EventForm
initialPersons={data.initialPersons}
initialDocuments={data.initialDocuments}
originPersonId={data.originPersonId}
form={form}
/>

View File

@@ -0,0 +1,206 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server';
import { load, actions } from './+page.server';
const mockFetch = vi.fn() as unknown as typeof fetch;
beforeEach(() => vi.clearAllMocks());
function localsWith(perms: string[] | null) {
if (perms === null) return { user: null };
return { user: { groups: [{ permissions: perms }] } };
}
function loadEvent(perms: string[] | null, search = '') {
const url = new URL(`http://localhost/zeitstrahl/events/new${search}`);
return {
locals: localsWith(perms),
url,
fetch: mockFetch,
request: new Request(url),
route: { id: '/zeitstrahl/events/new' },
params: {}
} as never;
}
function saveRequest(fields: Record<string, string | string[]>): Request {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) {
if (Array.isArray(v)) v.forEach((x) => fd.append(k, x));
else fd.set(k, v);
}
return new Request('http://localhost/zeitstrahl/events/new', { method: 'POST', body: fd });
}
function saveEvent(fields: Record<string, string | string[]>) {
return {
request: saveRequest(fields),
fetch: mockFetch,
route: { id: '/zeitstrahl/events/new' },
params: {}
} as never;
}
describe('zeitstrahl/events/new load — gating (REQ-002/003)', () => {
it('throws 403 for an unauthenticated (null) user', async () => {
await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 });
});
it('throws 403 for an authenticated user without WRITE_ALL', async () => {
await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
});
it('allows a curator with WRITE_ALL', async () => {
vi.mocked(createApiClient).mockReturnValue({ GET: vi.fn() } as never);
const result = await load(loadEvent(['WRITE_ALL']));
expect(result.initialPersons).toEqual([]);
expect(result.initialDocuments).toEqual([]);
});
});
describe('zeitstrahl/events/new load — prefill (REQ-014)', () => {
it('preselects a valid person and ignores an unknown document', async () => {
const get = vi.fn((path: string) => {
if (path === '/api/persons/{id}')
return Promise.resolve({
response: { ok: true },
data: { id: 'p1', displayName: 'Anna' }
});
return Promise.resolve({ response: { ok: false }, data: null });
});
vi.mocked(createApiClient).mockReturnValue({ GET: get } as never);
const result = await load(loadEvent(['WRITE_ALL'], '?personId=p1&documentId=missing'));
expect(result.initialPersons).toHaveLength(1);
expect(result.initialDocuments).toEqual([]);
expect(result.originPersonId).toBe('p1');
});
});
describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
const validUuid = '11111111-1111-1111-1111-111111111111';
it('posts a TimelineEventRequest and redirects on success', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
await expect(
actions.save(saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' }))
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
expect(post).toHaveBeenCalledTimes(1);
expect(post.mock.calls[0][1].body).toMatchObject({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01'
});
});
it('sends eventDateEnd: null when precision is not RANGE', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
try {
await actions.save(
saveEvent({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
metaDatePrecision: 'YEAR',
eventDateEnd: '1925-05-01'
})
);
} catch {
// redirect throws on success
}
expect(post.mock.calls[0][1].body.eventDateEnd).toBeNull();
});
it('returns fail(400) with preserved picker arrays on blank title', async () => {
const post = vi.fn();
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
const result = await actions.save(
saveEvent({
title: ' ',
type: 'PERSONAL',
eventDate: '1925-04-01',
personIds: ['p1', 'p2'],
documentIds: ['d1']
})
);
expect(post).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 400 });
expect(result.data.personIds).toEqual(['p1', 'p2']);
expect(result.data.documentIds).toEqual(['d1']);
expect(result.data.titleError).toBeTruthy();
});
it('surfaces both title and date errors when both blank (REQ-011)', async () => {
vi.mocked(createApiClient).mockReturnValue({ POST: vi.fn() } as never);
const result = await actions.save(saveEvent({ title: '', type: 'PERSONAL', eventDate: '' }));
expect(result.data.titleError).toBeTruthy();
expect(result.data.dateError).toBeTruthy();
});
it('redirects to /persons/{id} when originPersonId is a valid UUID', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
await expect(
actions.save(
saveEvent({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
originPersonId: validUuid
})
)
).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
});
it('defaults to /zeitstrahl when originPersonId is not a valid UUID (REQ-015)', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
await expect(
actions.save(
saveEvent({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
originPersonId: '../evil'
})
)
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
});
it('maps the API error and does not redirect on a non-ok save (incl. 409)', async () => {
const post = vi.fn().mockResolvedValue({
response: { ok: false, status: 409 },
error: { code: 'CONFLICT' }
});
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
const result = await actions.save(
saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
);
expect(result).toMatchObject({ status: 409 });
expect(result.data.error).toBeTruthy();
});
});