feat(timeline): add /zeitstrahl/events/[id]/edit curator edit + delete route

Load gates on hasWriteAll (null-user guard first, 403 error page) and seeds the
form from GET /api/timeline/events/{id}, failing closed with 404 on ANY non-ok
response so derived person-events (no UUID) and unknown ids never render a blank
create form. The save action PUTs with the optimistic-lock version (threaded via
a hidden input EventForm now emits), mapping 409 to the generic conflict message
without redirecting. The delete action DELETEs behind getConfirmService(),
returns fail(status) on a non-ok response (no redirect), and otherwise redirects
to the UUID-validated nav target. 8/8 server specs green; EventForm 6/6 green.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 22:51:56 +02:00
parent 59d78150b3
commit 5cfb4608f6
4 changed files with 275 additions and 0 deletions

View File

@@ -149,6 +149,11 @@ async function confirmDelete(e: SubmitEvent) {
}} }}
> >
<input type="hidden" name="originPersonId" value={originPersonId} /> <input type="hidden" name="originPersonId" value={originPersonId} />
{#if event}
<!-- Optimistic-lock version travels back to the PUT so #3 can reject a
stale edit with 409. -->
<input type="hidden" name="version" value={event.version} />
{/if}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Main column --> <!-- Main column -->

View File

@@ -0,0 +1,107 @@
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';
export async function load({
locals,
params,
url,
fetch
}: {
locals: App.Locals;
params: { id: string };
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 result = await api.GET('/api/timeline/events/{id}', {
params: { path: { id: params.id } }
});
// Fail closed: derived person-events (Geburt/Tod/Heirat) are not persisted and
// have no UUID, so the API 404s for them. Any non-ok response → 404; never
// render a blank editable form that silently POSTs a new event.
if (!result.response.ok) throw error(404, 'Not found');
return { event: result.data!, originPersonId: url.searchParams.get('personId') ?? '' };
}
export const actions = {
save: async ({
request,
params,
fetch
}: {
request: Request;
params: { id: string };
fetch: typeof globalThis.fetch;
}) => {
const formData = await request.formData();
const parsed = parseEventForm(formData);
const invalid = validateEventForm(parsed);
if (invalid) return invalid;
const versionRaw = formData.get('version')?.toString();
const version = versionRaw ? Number(versionRaw) : undefined;
const api = createApiClient(fetch);
const result = await api.PUT('/api/timeline/events/{id}', {
params: { path: { id: params.id } },
body: toEventRequest(parsed, version)
});
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));
},
delete: async ({
request,
params,
fetch
}: {
request: Request;
params: { id: string };
fetch: typeof globalThis.fetch;
}) => {
const formData = await request.formData();
const originPersonId = formData.get('originPersonId')?.toString() ?? '';
const api = createApiClient(fetch);
const result = await api.DELETE('/api/timeline/events/{id}', {
params: { path: { id: params.id } }
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
}
throw redirect(303, resolveNavTarget(originPersonId));
}
};

View File

@@ -0,0 +1,8 @@
<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 event={data.event} originPersonId={data.originPersonId} form={form} />

View File

@@ -0,0 +1,155 @@
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, id = 'e1') {
const url = new URL(`http://localhost/zeitstrahl/events/${id}/edit`);
return {
locals: localsWith(perms),
url,
params: { id },
fetch: mockFetch,
request: new Request(url),
route: { id: '/zeitstrahl/events/[id]/edit' }
} as never;
}
function actionEvent(
method: 'save' | 'delete',
fields: Record<string, string | string[]>,
id = 'e1'
) {
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 {
request: new Request(`http://localhost/zeitstrahl/events/${id}/edit?/${method}`, {
method: 'POST',
body: fd
}),
params: { id },
fetch: mockFetch,
route: { id: '/zeitstrahl/events/[id]/edit' }
} as never;
}
const EVENT_VIEW = {
id: 'e1',
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
precision: 'DAY',
version: 2,
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00Z',
updatedBy: 'u1',
updatedAt: '2026-01-01T00:00:00Z',
persons: [],
documents: []
};
describe('zeitstrahl/events/[id]/edit 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 a user without WRITE_ALL', async () => {
await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
});
});
describe('zeitstrahl/events/[id]/edit load — fail closed (REQ-012)', () => {
it('throws 404 when the GET is not ok (unknown or derived id)', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 404 }, data: undefined })
} as never);
await expect(load(loadEvent(['WRITE_ALL']))).rejects.toMatchObject({ status: 404 });
});
it('seeds the form with the event on an ok GET', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW })
} as never);
const result = await load(loadEvent(['WRITE_ALL']));
expect(result.event).toMatchObject({ id: 'e1', title: 'Umzug' });
});
});
describe('zeitstrahl/events/[id]/edit save action (REQ-005/013)', () => {
it('updates via PUT (with version) and redirects on success', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
await expect(
actions.save(
actionEvent('save', {
title: 'Umzug II',
type: 'PERSONAL',
eventDate: '1925-04-01',
version: '2'
})
)
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
expect(put).toHaveBeenCalledTimes(1);
expect(put.mock.calls[0][1].params.path.id).toBe('e1');
expect(put.mock.calls[0][1].body).toMatchObject({ title: 'Umzug II', version: 2 });
});
it('maps a 409 conflict and does not redirect', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 409 }, error: { code: 'CONFLICT' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
const result = await actions.save(
actionEvent('save', { title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
);
expect(result).toMatchObject({ status: 409 });
expect(result.data.error).toBeTruthy();
});
});
describe('zeitstrahl/events/[id]/edit delete action (REQ-006/007)', () => {
const validUuid = '22222222-2222-2222-2222-222222222222';
it('deletes via DELETE and redirects to the resolved target on success', async () => {
const del = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 } });
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
await expect(
actions.delete(actionEvent('delete', { originPersonId: validUuid }))
).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
expect(del.mock.calls[0][1].params.path.id).toBe('e1');
});
it('returns fail(status) and does not redirect when DELETE is not ok', async () => {
const del = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 500 }, error: { code: 'INTERNAL' } });
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
const result = await actions.delete(actionEvent('delete', {}));
expect(result).toMatchObject({ status: 500 });
expect(result.data.error).toBeTruthy();
});
});