Timeline: curator event create/edit forms (#781) #832
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;
|
||||
}
|
||||
81
frontend/src/routes/zeitstrahl/events/new/+page.server.ts
Normal file
81
frontend/src/routes/zeitstrahl/events/new/+page.server.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
13
frontend/src/routes/zeitstrahl/events/new/+page.svelte
Normal file
13
frontend/src/routes/zeitstrahl/events/new/+page.svelte
Normal 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}
|
||||
/>
|
||||
206
frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts
Normal file
206
frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user