From 5cfb4608f6c1fcef7b3f49728d5c8313c24ef6cd Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 22:51:56 +0200 Subject: [PATCH] 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 --- frontend/src/lib/timeline/EventForm.svelte | 5 + .../events/[id]/edit/+page.server.ts | 107 ++++++++++++ .../zeitstrahl/events/[id]/edit/+page.svelte | 8 + .../events/[id]/edit/page.server.spec.ts | 155 ++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts create mode 100644 frontend/src/routes/zeitstrahl/events/[id]/edit/+page.svelte create mode 100644 frontend/src/routes/zeitstrahl/events/[id]/edit/page.server.spec.ts diff --git a/frontend/src/lib/timeline/EventForm.svelte b/frontend/src/lib/timeline/EventForm.svelte index 1653d20e..755a2863 100644 --- a/frontend/src/lib/timeline/EventForm.svelte +++ b/frontend/src/lib/timeline/EventForm.svelte @@ -149,6 +149,11 @@ async function confirmDelete(e: SubmitEvent) { }} > + {#if event} + + + {/if}
diff --git a/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts b/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts new file mode 100644 index 00000000..20bad3b7 --- /dev/null +++ b/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts @@ -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)); + } +}; diff --git a/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.svelte b/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.svelte new file mode 100644 index 00000000..45f9348c --- /dev/null +++ b/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.svelte @@ -0,0 +1,8 @@ + + + diff --git a/frontend/src/routes/zeitstrahl/events/[id]/edit/page.server.spec.ts b/frontend/src/routes/zeitstrahl/events/[id]/edit/page.server.spec.ts new file mode 100644 index 00000000..8ae60729 --- /dev/null +++ b/frontend/src/routes/zeitstrahl/events/[id]/edit/page.server.spec.ts @@ -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, + 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(); + }); +});