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:
@@ -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 -->
|
||||||
|
|||||||
107
frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts
Normal file
107
frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts
Normal 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));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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} />
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user