From 57ed9379a27c2323d1055130bc2e95d06c5c9e62 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 13:46:25 +0200
Subject: [PATCH 01/20] feat(backend): add POST /api/documents/{id}/file
endpoint to attach file to existing document
Co-Authored-By: Claude Sonnet 4.6
---
.../controller/DocumentController.java | 14 +++++++++
.../service/DocumentService.java | 17 +++++++++++
.../controller/DocumentControllerTest.java | 30 +++++++++++++++++++
3 files changed, 61 insertions(+)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java
index 6dfb81fe..cc8f6707 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java
@@ -129,6 +129,20 @@ public class DocumentController {
return ResponseEntity.noContent().build();
}
+ // --- ATTACH FILE ---
+
+ @PostMapping(value = "/{id}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @RequirePermission(Permission.WRITE_ALL)
+ public Document attachFile(
+ @PathVariable UUID id,
+ @RequestPart("file") MultipartFile file) {
+ try {
+ return documentService.attachFile(id, file);
+ } catch (IOException e) {
+ throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
+ }
+ }
+
// --- QUICK UPLOAD ---
private static final Set ALLOWED_CONTENT_TYPES = Set.of(
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java
index 2f074e48..fecf7117 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java
@@ -286,6 +286,23 @@ public class DocumentService {
return documentRepository.save(doc);
}
+ @Transactional
+ public Document attachFile(UUID id, MultipartFile file) throws IOException {
+ Document doc = documentRepository.findById(id)
+ .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
+ FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
+ doc.setFilePath(upload.s3Key());
+ doc.setFileHash(upload.fileHash());
+ doc.setOriginalFilename(file.getOriginalFilename());
+ doc.setContentType(file.getContentType());
+ if (doc.getStatus() == DocumentStatus.PLACEHOLDER) {
+ doc.setStatus(DocumentStatus.UPLOADED);
+ }
+ Document saved = documentRepository.save(doc);
+ documentVersionService.recordVersion(saved);
+ return saved;
+ }
+
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
public List getRecentActivity(int size) {
return documentRepository.findAll(
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
index dd73aa91..4153556e 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
@@ -563,6 +563,36 @@ class DocumentControllerTest {
.andExpect(status().isBadRequest());
}
+ // ─── POST /api/documents/{id}/file ───────────────────────────────────────
+
+ @Test
+ @WithMockUser
+ void attachFile_returns403_whenMissingWritePermission() throws Exception {
+ org.springframework.mock.web.MockMultipartFile file =
+ new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
+
+ mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void attachFile_returns200_withUpdatedDocument_whenHasWritePermission() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder()
+ .id(id).title("Brief").originalFilename("brief.pdf")
+ .filePath("docs/brief.pdf").status(DocumentStatus.UPLOADED).build();
+ when(documentService.attachFile(eq(id), any())).thenReturn(doc);
+
+ org.springframework.mock.web.MockMultipartFile file =
+ new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
+
+ mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(id.toString()))
+ .andExpect(jsonPath("$.status").value("UPLOADED"));
+ }
+
// ─── GET /api/documents/{id}/versions/{versionId} ────────────────────────
@Test
--
2.49.1
From 255eeb660b58dd60edfc2a5a276c3cd98962c45b Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 13:53:10 +0200
Subject: [PATCH 02/20] feat(frontend): add autofocus prop to PersonTypeahead
forwarded to text input
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/components/PersonTypeahead.svelte | 3 +++
1 file changed, 3 insertions(+)
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte
index 985cdd42..f9bbece4 100644
--- a/frontend/src/lib/components/PersonTypeahead.svelte
+++ b/frontend/src/lib/components/PersonTypeahead.svelte
@@ -15,6 +15,7 @@ interface Props {
placeholder?: string;
compact?: boolean;
large?: boolean;
+ autofocus?: boolean;
restrictToCorrespondentsOf?: string;
onchange?: (value: string) => void;
onfocused?: () => void;
@@ -29,6 +30,7 @@ let {
placeholder,
compact = false,
large = false,
+ autofocus = false,
restrictToCorrespondentsOf,
onchange,
onfocused
@@ -121,6 +123,7 @@ function selectPerson(person: Person) {
type="text"
id="{name}-search"
autocomplete="off"
+ autofocus={autofocus}
bind:value={searchTerm}
oninput={handleInput}
onfocus={handleFocus}
--
2.49.1
From 77db791d0a0951987d7fc3665e452dd8b8b01c85 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 13:54:26 +0200
Subject: [PATCH 03/20] feat(frontend): reorder WhoWhenSection grid, expose
dateIso bindable, add autofocus
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Required fields (Datum, Absender) move to row 1; optional fields (Empfänger, Ort)
to row 2. dateIso is now bindable for the progress bar. Autofocus lands on the
first empty required field on page load.
Co-Authored-By: Claude Sonnet 4.6
---
.../components/document/WhoWhenSection.svelte | 44 ++++++++++---------
1 file changed, 24 insertions(+), 20 deletions(-)
diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte
index d86a6743..cddd9514 100644
--- a/frontend/src/lib/components/document/WhoWhenSection.svelte
+++ b/frontend/src/lib/components/document/WhoWhenSection.svelte
@@ -14,6 +14,7 @@ interface Person {
let {
senderId = $bindable(''),
selectedReceivers = $bindable([]),
+ dateIso = $bindable(''),
initialDateIso = '',
initialLocation = '',
initialSenderName = '',
@@ -22,6 +23,7 @@ let {
}: {
senderId?: string;
selectedReceivers?: Person[];
+ dateIso?: string;
initialDateIso?: string;
initialLocation?: string;
initialSenderName?: string;
@@ -30,7 +32,7 @@ let {
} = $props();
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
-let dateIso = $state(untrack(() => initialDateIso));
+dateIso = untrack(() => initialDateIso);
let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
@@ -57,7 +59,7 @@ $effect(() => {
-
+
{
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
+ autofocus={!initialDateIso}
class="block w-full rounded border border-line p-2 text-sm shadow-sm
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
@@ -80,7 +83,25 @@ $effect(() => {
{/if}
-
+
+
+
+
+
+
{m.form_label_receivers()}
+
+
+
+
{
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
-
-
-
-
-
-
-
{m.form_label_receivers()}
-
-
--
2.49.1
From 765d282923a44dcff2506320a6a11932d2f821e3 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 13:55:38 +0200
Subject: [PATCH 04/20] feat(frontend): reorder DescriptionSection fields,
expose currentTitle bindable, add Optional divider
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Field order: Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort.
currentTitle is now bindable so the enrich page can derive the required-fields progress bar.
Co-Authored-By: Claude Sonnet 4.6
---
.../document/DescriptionSection.svelte | 49 +++++++++++--------
1 file changed, 29 insertions(+), 20 deletions(-)
diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte
index ac5b7cd0..8479f9b9 100644
--- a/frontend/src/lib/components/document/DescriptionSection.svelte
+++ b/frontend/src/lib/components/document/DescriptionSection.svelte
@@ -5,6 +5,7 @@ import { m } from '$lib/paraglide/messages.js';
let {
tags = $bindable([]),
+ currentTitle = $bindable(''),
initialTitle = '',
initialDocumentLocation = '',
initialSummary = '',
@@ -13,6 +14,7 @@ let {
hideTitle = false
}: {
tags?: Tag[];
+ currentTitle?: string;
initialTitle?: string;
initialDocumentLocation?: string;
initialSummary?: string;
@@ -22,8 +24,8 @@ let {
} = $props();
let titleDirty = $state(false);
-let titleOverride = $state(untrack(() => initialTitle));
-let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOverride);
+currentTitle = untrack(() => initialTitle);
+const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
@@ -33,7 +35,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
{#if !hideTitle}
-
+
{/if}
-
-
-
-
-
{m.form_helper_archive_location()}
-
-
{m.form_label_tags()}
@@ -91,5 +77,28 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
>{initialSummary}
+
+
+
+
+
+
+
+
+
{m.form_helper_archive_location()}
+
--
2.49.1
From 44c7adbc24f724cf50c261c6eca1d0777fb57a20 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 13:56:32 +0200
Subject: [PATCH 05/20] feat(frontend): add depends('app:document') to enrich
load for targeted invalidation after file upload
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/routes/enrich/[id]/+page.server.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/frontend/src/routes/enrich/[id]/+page.server.ts b/frontend/src/routes/enrich/[id]/+page.server.ts
index f3e6b95d..fe1fcdb7 100644
--- a/frontend/src/routes/enrich/[id]/+page.server.ts
+++ b/frontend/src/routes/enrich/[id]/+page.server.ts
@@ -6,11 +6,13 @@ import { getErrorMessage, parseBackendError } from '$lib/errors';
export async function load({
params,
fetch,
- locals
+ locals,
+ depends
}: {
params: { id: string };
fetch: typeof globalThis.fetch;
locals: App.Locals;
+ depends: (dep: string) => void;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
@@ -18,6 +20,8 @@ export async function load({
) ?? false;
if (!canWrite) throw redirect(303, '/');
+ depends('app:document');
+
const { id } = params;
const api = createApiClient(fetch);
--
2.49.1
From 3893a104d3b36525a6415a853b04c5ab5e5802ae Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 13:57:44 +0200
Subject: [PATCH 06/20] feat(frontend): add @keyframes slide for indeterminate
upload progress animation
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/routes/layout.css | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css
index bd984cdf..aa4c6ba9 100644
--- a/frontend/src/routes/layout.css
+++ b/frontend/src/routes/layout.css
@@ -363,3 +363,12 @@
outline: 3px solid ButtonText;
}
}
+
+@keyframes slide {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(350%);
+ }
+}
--
2.49.1
From 14cfe8cae63709f612634637b4243424ab645e2d Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 14:03:28 +0200
Subject: [PATCH 07/20] feat(frontend): add UploadZone component for
PLACEHOLDER document file upload
Presentational component with idle/uploading/error states, drag-and-drop,
client-side MIME type + 50 MB size validation, accessible touch targets (44px),
aria-live region, and indeterminate progress animation.
Co-Authored-By: Claude Sonnet 4.6
---
.../lib/components/document/UploadZone.svelte | 100 ++++++++++++++++++
.../document/UploadZone.svelte.test.ts | 85 +++++++++++++++
2 files changed, 185 insertions(+)
create mode 100644 frontend/src/lib/components/document/UploadZone.svelte
create mode 100644 frontend/src/lib/components/document/UploadZone.svelte.test.ts
diff --git a/frontend/src/lib/components/document/UploadZone.svelte b/frontend/src/lib/components/document/UploadZone.svelte
new file mode 100644
index 00000000..50aac4eb
--- /dev/null
+++ b/frontend/src/lib/components/document/UploadZone.svelte
@@ -0,0 +1,100 @@
+
+
+
+ {#if isUploading}
+
+
+
{filename}
+
Wird hochgeladen …
+
+
+ {:else}
+
{
+ e.preventDefault();
+ isDragging = true;
+ }}
+ ondragleave={() => (isDragging = false)}
+ ondrop={handleDrop}
+ >
+
+ ↑
+
+
{filename}
+
Noch keine Datei hochgeladen
+ {#if displayError}
+
{displayError}
+ {/if}
+
+
oder Datei hier ablegen
+
+ {/if}
+
diff --git a/frontend/src/lib/components/document/UploadZone.svelte.test.ts b/frontend/src/lib/components/document/UploadZone.svelte.test.ts
new file mode 100644
index 00000000..a661a2a6
--- /dev/null
+++ b/frontend/src/lib/components/document/UploadZone.svelte.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import UploadZone from './UploadZone.svelte';
+
+describe('UploadZone', () => {
+ describe('idle state', () => {
+ it('shows the filename in the upload zone', async () => {
+ render(UploadZone, {
+ props: { filename: 'brief_1920.pdf', isUploading: false, isDragging: false, error: null }
+ });
+ await expect.element(page.getByText('brief_1920.pdf')).toBeVisible();
+ });
+
+ it('shows "Datei auswählen" button', async () => {
+ render(UploadZone, {
+ props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null }
+ });
+ await expect.element(page.getByText('Datei auswählen')).toBeVisible();
+ });
+
+ it('does not show the uploading animation', async () => {
+ render(UploadZone, {
+ props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null }
+ });
+ expect(document.querySelector('[role="status"]')).toBeNull();
+ });
+ });
+
+ describe('uploading state', () => {
+ it('shows the uploading progress region', async () => {
+ render(UploadZone, {
+ props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null }
+ });
+ await expect.element(page.getByRole('status')).toBeVisible();
+ });
+
+ it('shows Abbrechen button during upload', async () => {
+ render(UploadZone, {
+ props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null }
+ });
+ await expect.element(page.getByText('Abbrechen')).toBeVisible();
+ });
+
+ it('calls onCancel when Abbrechen is clicked', async () => {
+ const onCancel = vi.fn();
+ render(UploadZone, {
+ props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null, onCancel }
+ });
+ const btn = document.querySelector('button')!;
+ btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe('error state', () => {
+ it('shows the error message', async () => {
+ render(UploadZone, {
+ props: {
+ filename: 'scan.pdf',
+ isUploading: false,
+ isDragging: false,
+ error: 'Dateityp nicht unterstützt'
+ }
+ });
+ await expect.element(page.getByText('Dateityp nicht unterstützt')).toBeVisible();
+ });
+ });
+
+ describe('file selection', () => {
+ it('does not call onFile for an unsupported MIME type', async () => {
+ const onFile = vi.fn();
+ render(UploadZone, {
+ props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null, onFile }
+ });
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+ const docxFile = new File(['x'], 'test.docx', {
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ });
+ Object.defineProperty(input, 'files', { value: [docxFile], writable: false });
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(onFile).not.toHaveBeenCalled();
+ });
+ });
+});
--
2.49.1
From 93fc8696fdf29d81861bcbf315d2cb2ae652a232 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 14:05:09 +0200
Subject: [PATCH 08/20] feat(frontend): add countRequiredFilled utility with
all 8 field-combination tests
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/utils/requiredFields.test.ts | 36 +++++++++++++++++++
frontend/src/lib/utils/requiredFields.ts | 3 ++
2 files changed, 39 insertions(+)
create mode 100644 frontend/src/lib/utils/requiredFields.test.ts
create mode 100644 frontend/src/lib/utils/requiredFields.ts
diff --git a/frontend/src/lib/utils/requiredFields.test.ts b/frontend/src/lib/utils/requiredFields.test.ts
new file mode 100644
index 00000000..427dbefc
--- /dev/null
+++ b/frontend/src/lib/utils/requiredFields.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest';
+import { countRequiredFilled } from './requiredFields';
+
+describe('countRequiredFilled', () => {
+ it('returns 0 when all three fields are empty', () => {
+ expect(countRequiredFilled('', '', '')).toBe(0);
+ });
+
+ it('returns 1 when only title is set', () => {
+ expect(countRequiredFilled('Ein Brief', '', '')).toBe(1);
+ });
+
+ it('returns 1 when only dateIso is set', () => {
+ expect(countRequiredFilled('', '1920-05-01', '')).toBe(1);
+ });
+
+ it('returns 1 when only senderId is set', () => {
+ expect(countRequiredFilled('', '', 'person-uuid')).toBe(1);
+ });
+
+ it('returns 2 when title and dateIso are set', () => {
+ expect(countRequiredFilled('Ein Brief', '1920-05-01', '')).toBe(2);
+ });
+
+ it('returns 2 when title and senderId are set', () => {
+ expect(countRequiredFilled('Ein Brief', '', 'person-uuid')).toBe(2);
+ });
+
+ it('returns 2 when dateIso and senderId are set', () => {
+ expect(countRequiredFilled('', '1920-05-01', 'person-uuid')).toBe(2);
+ });
+
+ it('returns 3 when all three fields are set', () => {
+ expect(countRequiredFilled('Ein Brief', '1920-05-01', 'person-uuid')).toBe(3);
+ });
+});
diff --git a/frontend/src/lib/utils/requiredFields.ts b/frontend/src/lib/utils/requiredFields.ts
new file mode 100644
index 00000000..58b7076d
--- /dev/null
+++ b/frontend/src/lib/utils/requiredFields.ts
@@ -0,0 +1,3 @@
+export function countRequiredFilled(title: string, dateIso: string, senderId: string): number {
+ return [title, dateIso, senderId].filter(Boolean).length;
+}
--
2.49.1
From 836d30e2624f5f8f5c93ddc239593354f5e88990 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 14:07:23 +0200
Subject: [PATCH 09/20] feat(frontend): wire progress bar, upload zone, and
file replace into enrich page
- Required-fields progress bar (Pflichtfelder) with role="progressbar" ARIA tracks
Titel, Datum, and Absender live via bound props from child components
- Left panel shows UploadZone for PLACEHOLDER documents (no filePath); after upload
invalidates 'app:document' to transition to PDF viewer without page reload
- AbortController powers the cancel button during upload
- "Datei ersetzen" ghost button lives in a thin toolbar above the PDF viewer
- dateIso and currentTitle are now bound from WhoWhenSection/DescriptionSection
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/routes/enrich/[id]/+page.svelte | 128 ++++++++++++++++---
1 file changed, 112 insertions(+), 16 deletions(-)
diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte
index 783326bc..6b1eea0a 100644
--- a/frontend/src/routes/enrich/[id]/+page.svelte
+++ b/frontend/src/routes/enrich/[id]/+page.svelte
@@ -1,9 +1,12 @@
@@ -67,20 +118,61 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
+
+
+
Pflichtfelder
+
+
{requiredFilled} / 3
+
+
-
-
-
{}}
- />
+
+
+ {#if !doc.filePath}
+
+ {:else}
+
+
+
+
+
+ {}}
+ />
+
+ {/if}
@@ -102,13 +194,17 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
+
-
--
2.49.1
From 270005e0da372841fd214d4f326a75cdde6792ef Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 14:27:22 +0200
Subject: [PATCH 10/20] fix(backend): move IOException into service, add
content-type whitelist to attachFile
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DocumentService.attachFile() now catches IOException internally and
re-throws as DomainException.internal — the IOException no longer leaks
through the service boundary
- DocumentController.attachFile() is now a plain delegate (no try/catch)
- ALLOWED_CONTENT_TYPES whitelist (PDF/JPEG/PNG/TIFF) is now enforced on
the attachFile endpoint, matching the existing quick-upload validation
- Added 5 DocumentService unit tests for attachFile (notFound, status
transition PLACEHOLDER→UPLOADED, no-change when already UPLOADED,
field assignment from upload result, IOException→DomainException)
- Added controller tests: 400 on disallowed content type, 404 on missing doc
Co-Authored-By: Claude Sonnet 4.6
---
.../controller/DocumentController.java | 14 ++--
.../service/DocumentService.java | 9 ++-
.../controller/DocumentControllerTest.java | 29 ++++++++
.../service/DocumentServiceTest.java | 71 +++++++++++++++++++
4 files changed, 114 insertions(+), 9 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java
index cc8f6707..ba34092b 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java
@@ -131,23 +131,23 @@ public class DocumentController {
// --- ATTACH FILE ---
+ private static final Set ALLOWED_CONTENT_TYPES = Set.of(
+ "application/pdf", "image/jpeg", "image/png", "image/tiff");
+
@PostMapping(value = "/{id}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePermission(Permission.WRITE_ALL)
public Document attachFile(
@PathVariable UUID id,
@RequestPart("file") MultipartFile file) {
- try {
- return documentService.attachFile(id, file);
- } catch (IOException e) {
- throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
+ String contentType = file.getContentType();
+ if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type: " + contentType);
}
+ return documentService.attachFile(id, file);
}
// --- QUICK UPLOAD ---
- private static final Set ALLOWED_CONTENT_TYPES = Set.of(
- "application/pdf", "image/jpeg", "image/png", "image/tiff");
-
public record UploadError(String filename, String code) {}
public record QuickUploadResult(List created, List updated, List errors) {}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java
index fecf7117..b2d14d87 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java
@@ -287,10 +287,15 @@ public class DocumentService {
}
@Transactional
- public Document attachFile(UUID id, MultipartFile file) throws IOException {
+ public Document attachFile(UUID id, MultipartFile file) {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
- FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
+ FileService.UploadResult upload;
+ try {
+ upload = fileService.uploadFile(file, file.getOriginalFilename());
+ } catch (IOException e) {
+ throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
+ }
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setOriginalFilename(file.getOriginalFilename());
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
index 4153556e..8bb86ed0 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java
@@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
+import org.raddatz.familienarchiv.exception.DomainException;
+import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion;
@@ -593,6 +595,33 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.status").value("UPLOADED"));
}
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void attachFile_returns400_whenContentTypeIsNotWhitelisted() throws Exception {
+ org.springframework.mock.web.MockMultipartFile htmlFile =
+ new org.springframework.mock.web.MockMultipartFile(
+ "file", "evil.html", "text/html", "".getBytes());
+
+ mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void attachFile_returns404_whenDocumentDoesNotExist() throws Exception {
+ UUID id = UUID.randomUUID();
+ when(documentService.attachFile(eq(id), any()))
+ .thenThrow(DomainException.notFound(
+ ErrorCode.DOCUMENT_NOT_FOUND,
+ "Document not found: " + id));
+
+ org.springframework.mock.web.MockMultipartFile file =
+ new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
+
+ mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
+ .andExpect(status().isNotFound());
+ }
+
// ─── GET /api/documents/{id}/versions/{versionId} ────────────────────────
@Test
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java
index 2070dd46..69e127e2 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java
@@ -1428,4 +1428,75 @@ class DocumentServiceTest {
new MatchOffset(10, 4) // "Welt"
);
}
+
+ // ─── attachFile ───────────────────────────────────────────────────────────
+
+ @Test
+ void attachFile_throwsNotFound_whenDocumentDoesNotExist() {
+ UUID id = UUID.randomUUID();
+ when(documentRepository.findById(id)).thenReturn(Optional.empty());
+
+ MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
+ assertThatThrownBy(() -> documentService.attachFile(id, file))
+ .isInstanceOf(DomainException.class)
+ .hasMessageContaining(id.toString());
+ }
+
+ @Test
+ void attachFile_setsStatusToUploaded_whenDocumentIsPlaceholder() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document placeholder = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
+ when(documentRepository.findById(id)).thenReturn(Optional.of(placeholder));
+ when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3/key.pdf", "abc123"));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
+ Document result = documentService.attachFile(id, file);
+
+ assertThat(result.getStatus()).isEqualTo(DocumentStatus.UPLOADED);
+ }
+
+ @Test
+ void attachFile_doesNotChangeStatus_whenAlreadyUploaded() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document uploaded = Document.builder().id(id).status(DocumentStatus.UPLOADED).build();
+ when(documentRepository.findById(id)).thenReturn(Optional.of(uploaded));
+ when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3/key.pdf", "abc123"));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
+ Document result = documentService.attachFile(id, file);
+
+ assertThat(result.getStatus()).isEqualTo(DocumentStatus.UPLOADED);
+ }
+
+ @Test
+ void attachFile_setsFilePathAndContentType_fromUploadResult() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(fileService.uploadFile(any(), any()))
+ .thenReturn(new FileService.UploadResult("s3/brief.pdf", "deadbeef"));
+ when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
+ Document result = documentService.attachFile(id, file);
+
+ assertThat(result.getFilePath()).isEqualTo("s3/brief.pdf");
+ assertThat(result.getFileHash()).isEqualTo("deadbeef");
+ assertThat(result.getContentType()).isEqualTo("application/pdf");
+ assertThat(result.getOriginalFilename()).isEqualTo("brief.pdf");
+ }
+
+ @Test
+ void attachFile_throwsDomainException_whenFileUploadFails() throws Exception {
+ UUID id = UUID.randomUUID();
+ Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
+ when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
+ when(fileService.uploadFile(any(), any())).thenThrow(new java.io.IOException("storage unavailable"));
+
+ MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
+ assertThatThrownBy(() -> documentService.attachFile(id, file))
+ .isInstanceOf(DomainException.class);
+ }
}
--
2.49.1
From 0e1f07672737af42484e59d96b3fb09aadd0014a Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 14:31:05 +0200
Subject: [PATCH 11/20] fix(a11y): bump progress bar text to text-xs minimum,
add motion-safe to upload animation
- text-[9px]/text-[10px] in required-fields bar raised to text-xs (12px),
meeting the project minimum for the 60+ audience (WCAG 1.4.4)
- Upload animation now uses motion-safe: prefix so it stops for users
with prefers-reduced-motion set (WCAG 2.1 SC 2.3.3)
- Strengthened UploadZone tests: onCancel uses [role=status] button
selector instead of first-button heuristic; added positive file
selection test (valid PDF calls onFile), file-too-large test, and
MIME rejection now also asserts the error message is visible
Co-Authored-By: Claude Sonnet 4.6
---
.../lib/components/document/UploadZone.svelte | 4 ++-
.../document/UploadZone.svelte.test.ts | 32 ++++++++++++++++++-
frontend/src/routes/enrich/[id]/+page.svelte | 4 +--
3 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/frontend/src/lib/components/document/UploadZone.svelte b/frontend/src/lib/components/document/UploadZone.svelte
index 50aac4eb..62d3c672 100644
--- a/frontend/src/lib/components/document/UploadZone.svelte
+++ b/frontend/src/lib/components/document/UploadZone.svelte
@@ -55,7 +55,9 @@ function handleDrop(e: DragEvent) {
{#if isUploading}
{filename}
Wird hochgeladen …
diff --git a/frontend/src/lib/components/document/UploadZone.svelte.test.ts b/frontend/src/lib/components/document/UploadZone.svelte.test.ts
index a661a2a6..553d8638 100644
--- a/frontend/src/lib/components/document/UploadZone.svelte.test.ts
+++ b/frontend/src/lib/components/document/UploadZone.svelte.test.ts
@@ -47,7 +47,8 @@ describe('UploadZone', () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null, onCancel }
});
- const btn = document.querySelector('button')!;
+ // Click the button inside [role="status"] — more specific than querySelector('button')
+ const btn = document.querySelector('[role="status"] button') as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onCancel).toHaveBeenCalledOnce();
});
@@ -68,6 +69,18 @@ describe('UploadZone', () => {
});
describe('file selection', () => {
+ it('calls onFile for a valid PDF', () => {
+ const onFile = vi.fn();
+ render(UploadZone, {
+ props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null, onFile }
+ });
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+ const pdf = new File(['%PDF-1.4'], 'brief.pdf', { type: 'application/pdf' });
+ Object.defineProperty(input, 'files', { value: [pdf], writable: false });
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(onFile).toHaveBeenCalledWith(pdf);
+ });
+
it('does not call onFile for an unsupported MIME type', async () => {
const onFile = vi.fn();
render(UploadZone, {
@@ -80,6 +93,23 @@ describe('UploadZone', () => {
Object.defineProperty(input, 'files', { value: [docxFile], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
expect(onFile).not.toHaveBeenCalled();
+ await expect
+ .element(page.getByText('Dieser Dateityp wird nicht unterstützt (PDF, JPG, PNG, TIFF).'))
+ .toBeVisible();
+ });
+
+ it('does not call onFile when file exceeds 50 MB', async () => {
+ const onFile = vi.fn();
+ render(UploadZone, {
+ props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null, onFile }
+ });
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+ const bigFile = new File(['x'.repeat(1)], 'huge.pdf', { type: 'application/pdf' });
+ Object.defineProperty(bigFile, 'size', { value: 51 * 1024 * 1024 });
+ Object.defineProperty(input, 'files', { value: [bigFile], writable: false });
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(onFile).not.toHaveBeenCalled();
+ await expect.element(page.getByText('Die Datei ist zu groß (max. 50 MB).')).toBeVisible();
});
});
});
diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte
index 6b1eea0a..08578094 100644
--- a/frontend/src/routes/enrich/[id]/+page.svelte
+++ b/frontend/src/routes/enrich/[id]/+page.svelte
@@ -120,7 +120,7 @@ async function handleReplaceFile(e: Event) {
-
Pflichtfelder
+
Pflichtfelder
-
{requiredFilled} / 3
+
{requiredFilled} / 3
--
2.49.1
From 02cc08dfc67a2f7f854d94eaf331da236450b1be Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 14:38:29 +0200
Subject: [PATCH 12/20] fix(a11y): bump Optional divider label to text-xs
minimum (WCAG 1.4.4)
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/components/document/DescriptionSection.svelte | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte
index 8479f9b9..f629923b 100644
--- a/frontend/src/lib/components/document/DescriptionSection.svelte
+++ b/frontend/src/lib/components/document/DescriptionSection.svelte
@@ -81,7 +81,7 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
--
2.49.1
From a8c8c3fbcf6aa7182700aca03e19deb6f042e583 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 14:54:06 +0200
Subject: [PATCH 13/20] fix(i18n): replace hardcoded strings with Paraglide
message keys
- error_file_upload_failed key used in enrich upload handler
- label_optional key added (de/en/es) and used in DescriptionSection divider
Co-Authored-By: Claude Sonnet 4.6
---
frontend/messages/de.json | 1 +
frontend/messages/en.json | 1 +
frontend/messages/es.json | 1 +
.../src/lib/components/document/DescriptionSection.svelte | 4 +++-
frontend/src/routes/enrich/[id]/+page.svelte | 2 +-
5 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 5417f747..e3a7d610 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -52,6 +52,7 @@
"form_label_archive_location": "Aufbewahrungsort",
"form_placeholder_archive_location": "z.B. Schrank 3, Mappe B",
"form_helper_archive_location": "Wo befindet sich das Originaldokument?",
+ "label_optional": "Optional",
"login_heading": "Anmelden",
"login_label_username": "Benutzername",
"login_label_password": "Passwort",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index b2e76304..3f98a61e 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -52,6 +52,7 @@
"form_label_archive_location": "Storage location",
"form_placeholder_archive_location": "e.g. Cabinet 3, Folder B",
"form_helper_archive_location": "Where is the original document stored?",
+ "label_optional": "Optional",
"login_heading": "Sign in",
"login_label_username": "Username",
"login_label_password": "Password",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 96f9efc8..68ff5a7d 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -52,6 +52,7 @@
"form_label_archive_location": "Ubicación de almacenamiento",
"form_placeholder_archive_location": "p.ej. Armario 3, Carpeta B",
"form_helper_archive_location": "¿Dónde se encuentra el documento original?",
+ "label_optional": "Opcional",
"login_heading": "Iniciar sesión",
"login_label_username": "Usuario",
"login_label_password": "Contraseña",
diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte
index f629923b..f26c96fb 100644
--- a/frontend/src/lib/components/document/DescriptionSection.svelte
+++ b/frontend/src/lib/components/document/DescriptionSection.svelte
@@ -81,7 +81,9 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
-
Optional
+
{m.label_optional()}
diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte
index 08578094..2bd05b74 100644
--- a/frontend/src/routes/enrich/[id]/+page.svelte
+++ b/frontend/src/routes/enrich/[id]/+page.svelte
@@ -70,7 +70,7 @@ async function handleFile(file: File) {
await invalidate('app:document');
} catch (e) {
if ((e as Error).name === 'AbortError') return;
- uploadError = 'Upload fehlgeschlagen. Bitte erneut versuchen.';
+ uploadError = m.error_file_upload_failed();
} finally {
isUploading = false;
abortController = null;
--
2.49.1
From 8149949de8737a54dab8e0cdbc32291283aaf254 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 15:50:21 +0200
Subject: [PATCH 14/20] feat(edit): unify edit page with enrich split-panel
layout
Extract DocumentEditLayout shared component for the PDF+form split-panel
UI, replacing the old scrolling layout on /documents/[id]/edit with the
same fixed-panel structure used by /enrich/[id]. Removes TranscriptionSection
and FileSectionEdit from the edit page; file upload/replace is now handled
by the shared layout. Delete SaveBar and FileSectionEdit as dead code.
Co-Authored-By: Claude Sonnet 4.6
---
.../document/DocumentEditLayout.svelte | 221 ++++++++++++++++
.../components/document/WhoWhenSection.svelte | 7 +-
.../documents/[id]/edit/+page.server.ts | 5 +-
.../routes/documents/[id]/edit/+page.svelte | 131 ++++++----
.../[id]/edit/FileSectionEdit.svelte | 62 -----
.../routes/documents/[id]/edit/SaveBar.svelte | 76 ------
.../[id]/edit/SaveBar.svelte.spec.ts | 92 -------
frontend/src/routes/enrich/[id]/+page.svelte | 240 +++---------------
8 files changed, 338 insertions(+), 496 deletions(-)
create mode 100644 frontend/src/lib/components/document/DocumentEditLayout.svelte
delete mode 100644 frontend/src/routes/documents/[id]/edit/FileSectionEdit.svelte
delete mode 100644 frontend/src/routes/documents/[id]/edit/SaveBar.svelte
delete mode 100644 frontend/src/routes/documents/[id]/edit/SaveBar.svelte.spec.ts
diff --git a/frontend/src/lib/components/document/DocumentEditLayout.svelte b/frontend/src/lib/components/document/DocumentEditLayout.svelte
new file mode 100644
index 00000000..8fe0664f
--- /dev/null
+++ b/frontend/src/lib/components/document/DocumentEditLayout.svelte
@@ -0,0 +1,221 @@
+
+
+
+
+
+ {@render topbar()}
+
+
+
+
+
Pflichtfelder
+
+
{requiredFilled} / 3
+
+
+
+
+
+
+ {#if !doc.filePath}
+
+ {:else}
+
+
+
+
+
+ {}}
+ />
+
+ {/if}
+
+
+
+
+ {#if formError}
+
+ {formError}
+
+ {/if}
+
+
+
+
+
+ {@render actionbar()}
+
+
+
+
diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte
index cddd9514..6a1dfe2a 100644
--- a/frontend/src/lib/components/document/WhoWhenSection.svelte
+++ b/frontend/src/lib/components/document/WhoWhenSection.svelte
@@ -4,12 +4,9 @@ import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
+import type { components } from '$lib/generated/api';
-interface Person {
- id: string;
- firstName: string;
- lastName: string;
-}
+type Person = components['schemas']['Person'];
let {
senderId = $bindable(''),
diff --git a/frontend/src/routes/documents/[id]/edit/+page.server.ts b/frontend/src/routes/documents/[id]/edit/+page.server.ts
index de11c47a..f8e28fe5 100644
--- a/frontend/src/routes/documents/[id]/edit/+page.server.ts
+++ b/frontend/src/routes/documents/[id]/edit/+page.server.ts
@@ -6,12 +6,15 @@ import { parseBackendError, getErrorMessage } from '$lib/errors';
export async function load({
params,
fetch,
- locals
+ locals,
+ depends
}: {
params: { id: string };
fetch: typeof globalThis.fetch;
locals: App.Locals;
+ depends: (dep: string) => void;
}) {
+ depends('app:document');
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
diff --git a/frontend/src/routes/documents/[id]/edit/+page.svelte b/frontend/src/routes/documents/[id]/edit/+page.svelte
index 6bc970f1..936407b4 100644
--- a/frontend/src/routes/documents/[id]/edit/+page.svelte
+++ b/frontend/src/routes/documents/[id]/edit/+page.svelte
@@ -1,27 +1,34 @@
-
-
-
+
+ {doc.title || doc.originalFilename || 'Dokument'} — {m.doc_edit_heading()}
+
+
+
+ {#snippet topbar()}
{m.btn_back_to_document()}
-
- {m.doc_edit_heading()} —
- {doc.title || doc.originalFilename}
-
-
- {#if form?.error}
-
{form.error}
- {/if}
+
+ {doc.title || doc.originalFilename}
+
-
+
+ {/snippet}
-
-
-
+ {#snippet actionbar()}
+
+
+
+ {/snippet}
+
+
+
+
diff --git a/frontend/src/routes/documents/[id]/edit/FileSectionEdit.svelte b/frontend/src/routes/documents/[id]/edit/FileSectionEdit.svelte
deleted file mode 100644
index 6a30be46..00000000
--- a/frontend/src/routes/documents/[id]/edit/FileSectionEdit.svelte
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
-
-
-
- {m.doc_section_file()}
-
-
-
-
-
-

-
{m.doc_current_file_label()}
- {originalFilename}
-
-
-
-
-
-
diff --git a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte b/frontend/src/routes/documents/[id]/edit/SaveBar.svelte
deleted file mode 100644
index 477f3582..00000000
--- a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte
+++ /dev/null
@@ -1,76 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte.spec.ts b/frontend/src/routes/documents/[id]/edit/SaveBar.svelte.spec.ts
deleted file mode 100644
index d1ad0d97..00000000
--- a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte.spec.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { describe, it, expect, vi, afterEach } from 'vitest';
-import { cleanup, render } from 'vitest-browser-svelte';
-import { page } from 'vitest/browser';
-import SaveBar from './SaveBar.svelte';
-import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
-
-let appendedForms: HTMLFormElement[] = [];
-
-afterEach(() => {
- cleanup();
- appendedForms.forEach((f) => f.remove());
- appendedForms = [];
-});
-
-function renderSaveBar(docId = 'doc-1') {
- const service = createConfirmService();
-
- // Mount a dummy delete form so SaveBar can find it via document.getElementById
- const deleteForm = document.createElement('form');
- deleteForm.id = 'delete-form';
- document.body.appendChild(deleteForm);
- appendedForms.push(deleteForm);
-
- const result = render(SaveBar, {
- props: { docId },
- context: new Map([[CONFIRM_KEY, service]])
- });
-
- return { ...result, service, deleteForm };
-}
-
-// ─── Rendering ────────────────────────────────────────────────────────────────
-
-describe('SaveBar — rendering', () => {
- it('renders save button', async () => {
- renderSaveBar();
- await expect.element(page.getByRole('button', { name: /Speichern/i })).toBeInTheDocument();
- });
-
- it('renders delete button', async () => {
- renderSaveBar();
- // The delete button should be type="button" (async confirm flow)
- const deleteBtn = document.querySelector('button[type="button"]');
- expect(deleteBtn).not.toBeNull();
- });
-
- it('renders cancel link pointing to /documents/doc-1', async () => {
- renderSaveBar();
- await expect
- .element(page.getByRole('link', { name: /Abbrechen/i }))
- .toHaveAttribute('href', '/documents/doc-1');
- });
-});
-
-// ─── Delete confirmation ──────────────────────────────────────────────────────
-
-describe('SaveBar — delete confirmation', () => {
- it('opens confirm dialog when delete button is clicked', async () => {
- const { service } = renderSaveBar();
- const deleteBtn = document.querySelectorAll('button[type="button"]')[0];
- deleteBtn.click();
- await vi.waitFor(() => expect(service.options).not.toBeNull());
- expect(service.options?.destructive).toBe(true);
- service.settle(false);
- });
-
- it('submits delete form when user confirms', async () => {
- const { service, deleteForm } = renderSaveBar();
- const requestSubmit = vi.spyOn(deleteForm, 'requestSubmit').mockImplementation(() => {});
-
- const deleteBtn = document.querySelectorAll('button[type="button"]')[0];
- deleteBtn.click();
- await vi.waitFor(() => expect(service.options).not.toBeNull());
- service.settle(true);
- await vi.waitFor(() => expect(service.options).toBeNull());
-
- expect(requestSubmit).toHaveBeenCalledOnce();
- });
-
- it('does not submit delete form when user cancels', async () => {
- const { service, deleteForm } = renderSaveBar();
- const requestSubmit = vi.spyOn(deleteForm, 'requestSubmit').mockImplementation(() => {});
-
- const deleteBtn = document.querySelectorAll('button[type="button"]')[0];
- deleteBtn.click();
- await vi.waitFor(() => expect(service.options).not.toBeNull());
- service.settle(false);
- await vi.waitFor(() => expect(service.options).toBeNull());
-
- expect(requestSubmit).not.toHaveBeenCalled();
- });
-});
diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte
index 2bd05b74..81d72d49 100644
--- a/frontend/src/routes/enrich/[id]/+page.svelte
+++ b/frontend/src/routes/enrich/[id]/+page.svelte
@@ -1,101 +1,19 @@
{doc.title || doc.originalFilename || 'Dokument'} — Anreicherung
-
-
-
+ {/snippet}
-
-
-
Pflichtfelder
-
-
{requiredFilled} / 3
-
+ {m.enrich_skip()}
+
-
-
-
-
- {#if !doc.filePath}
-
- {:else}
-
-
-
-
-
- {}}
- />
-
- {/if}
-
-
-
-
- {#if form?.error}
-
- {form.error}
-
- {/if}
-
-
+ {m.btn_save()}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+ {/snippet}
+
+
+
--
2.49.1
From 9daf43834bf128356c9887f96f4ea7dd628ffc23 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 16:08:59 +0200
Subject: [PATCH 15/20] feat(upload): replace Unicode arrow with SVG icon in
UploadZone
Co-Authored-By: Claude Sonnet 4.6
---
.../lib/components/document/UploadZone.svelte | 17 +++++++++++--
.../document/UploadZone.svelte.spec.ts | 25 +++++++++++++++++++
2 files changed, 40 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/lib/components/document/UploadZone.svelte.spec.ts
diff --git a/frontend/src/lib/components/document/UploadZone.svelte b/frontend/src/lib/components/document/UploadZone.svelte
index 62d3c672..10c8c574 100644
--- a/frontend/src/lib/components/document/UploadZone.svelte
+++ b/frontend/src/lib/components/document/UploadZone.svelte
@@ -81,8 +81,21 @@ function handleDrop(e: DragEvent) {
ondragleave={() => (isDragging = false)}
ondrop={handleDrop}
>
-
- ↑
+
{filename}
Noch keine Datei hochgeladen
diff --git a/frontend/src/lib/components/document/UploadZone.svelte.spec.ts b/frontend/src/lib/components/document/UploadZone.svelte.spec.ts
new file mode 100644
index 00000000..47ff8b88
--- /dev/null
+++ b/frontend/src/lib/components/document/UploadZone.svelte.spec.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import UploadZone from './UploadZone.svelte';
+
+afterEach(cleanup);
+
+describe('UploadZone', () => {
+ it('renders an SVG arrow icon (not a Unicode character) in idle state', async () => {
+ const { container } = render(UploadZone, {
+ filename: 'test.pdf',
+ isUploading: false,
+ error: null
+ });
+ // The icon must be an SVG element, not the raw "↑" text
+ const svg = container.querySelector('.upload-zone-icon svg');
+ expect(svg).not.toBeNull();
+ expect(container.textContent).not.toContain('↑');
+ });
+
+ it('shows the filename in idle state', async () => {
+ render(UploadZone, { filename: 'my-document.pdf', isUploading: false, error: null });
+ await expect.element(page.getByText('my-document.pdf')).toBeInTheDocument();
+ });
+});
--
2.49.1
From ddbd6ef92fa3378b7759ed7e8db56a2f291a7a0b Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 16:12:01 +0200
Subject: [PATCH 16/20] feat(i18n): extract hardcoded strings in
DocumentEditLayout to i18n keys
Adds label_required_fields to all three locales. Fixes "Datei ersetzen"
toolbar colors to use semantic ink tokens (readable in both light and dark
pdf-bg themes).
Co-Authored-By: Claude Sonnet 4.6
---
frontend/messages/de.json | 1 +
frontend/messages/en.json | 1 +
frontend/messages/es.json | 1 +
.../components/document/DocumentEditLayout.svelte | 12 +++++++-----
4 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index e3a7d610..1ded45d1 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -53,6 +53,7 @@
"form_placeholder_archive_location": "z.B. Schrank 3, Mappe B",
"form_helper_archive_location": "Wo befindet sich das Originaldokument?",
"label_optional": "Optional",
+ "label_required_fields": "Pflichtfelder",
"login_heading": "Anmelden",
"login_label_username": "Benutzername",
"login_label_password": "Passwort",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 3f98a61e..98e0faab 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -53,6 +53,7 @@
"form_placeholder_archive_location": "e.g. Cabinet 3, Folder B",
"form_helper_archive_location": "Where is the original document stored?",
"label_optional": "Optional",
+ "label_required_fields": "Required fields",
"login_heading": "Sign in",
"login_label_username": "Username",
"login_label_password": "Password",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 68ff5a7d..1a4e11df 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -53,6 +53,7 @@
"form_placeholder_archive_location": "p.ej. Armario 3, Carpeta B",
"form_helper_archive_location": "¿Dónde se encuentra el documento original?",
"label_optional": "Opcional",
+ "label_required_fields": "Campos obligatorios",
"login_heading": "Iniciar sesión",
"login_label_username": "Usuario",
"login_label_password": "Contraseña",
diff --git a/frontend/src/lib/components/document/DocumentEditLayout.svelte b/frontend/src/lib/components/document/DocumentEditLayout.svelte
index 8fe0664f..61024d00 100644
--- a/frontend/src/lib/components/document/DocumentEditLayout.svelte
+++ b/frontend/src/lib/components/document/DocumentEditLayout.svelte
@@ -125,14 +125,16 @@ async function handleReplaceFile(e: Event) {
-
Pflichtfelder
+
{m.label_required_fields()}
{:else}
-
+
--
2.49.1
From aff485400df7aeaf8cfeded80426e584c1dbf24d Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 16:15:22 +0200
Subject: [PATCH 17/20] feat(upload): validate MIME type and size on file
replace in DocumentEditLayout
Co-Authored-By: Claude Sonnet 4.6
---
frontend/messages/de.json | 1 +
frontend/messages/en.json | 1 +
frontend/messages/es.json | 1 +
.../document/DocumentEditLayout.svelte | 10 ++++++
frontend/src/lib/utils/validateFile.spec.ts | 36 +++++++++++++++++++
frontend/src/lib/utils/validateFile.ts | 10 ++++++
6 files changed, 59 insertions(+)
create mode 100644 frontend/src/lib/utils/validateFile.spec.ts
create mode 100644 frontend/src/lib/utils/validateFile.ts
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 1ded45d1..41278bc6 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -11,6 +11,7 @@
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
"error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.",
"error_unsupported_file_type": "Dieses Dateiformat wird nicht unterstützt.",
+ "error_file_too_large": "Die Datei ist zu groß (max. 50 MB).",
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
"error_unauthorized": "Sie sind nicht angemeldet.",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 98e0faab..1c6e4b6e 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -11,6 +11,7 @@
"error_file_not_found": "The file could not be found in storage.",
"error_file_upload_failed": "The file could not be uploaded.",
"error_unsupported_file_type": "This file format is not supported.",
+ "error_file_too_large": "The file is too large (max. 50 MB).",
"error_user_not_found": "User not found.",
"error_import_already_running": "An import is already running. Please wait for it to finish.",
"error_unauthorized": "You are not logged in.",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 1a4e11df..254e17cd 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -11,6 +11,7 @@
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
"error_file_upload_failed": "No se pudo subir el archivo.",
"error_unsupported_file_type": "Este formato de archivo no está admitido.",
+ "error_file_too_large": "El archivo es demasiado grande (máx. 50 MB).",
"error_user_not_found": "Usuario no encontrado.",
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
"error_unauthorized": "No ha iniciado sesión.",
diff --git a/frontend/src/lib/components/document/DocumentEditLayout.svelte b/frontend/src/lib/components/document/DocumentEditLayout.svelte
index 61024d00..04bb203c 100644
--- a/frontend/src/lib/components/document/DocumentEditLayout.svelte
+++ b/frontend/src/lib/components/document/DocumentEditLayout.svelte
@@ -6,6 +6,7 @@ import type { Snippet } from 'svelte';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { m } from '$lib/paraglide/messages.js';
import { countRequiredFilled } from '$lib/utils/requiredFields';
+import { validateFile } from '$lib/utils/validateFile';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import UploadZone from '$lib/components/document/UploadZone.svelte';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
@@ -113,6 +114,15 @@ function cancelUpload() {
async function handleReplaceFile(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
+ const validationError = validateFile(file);
+ if (validationError === 'type') {
+ uploadError = m.error_unsupported_file_type();
+ return;
+ }
+ if (validationError === 'size') {
+ uploadError = m.error_file_too_large();
+ return;
+ }
await handleFile(file);
}
diff --git a/frontend/src/lib/utils/validateFile.spec.ts b/frontend/src/lib/utils/validateFile.spec.ts
new file mode 100644
index 00000000..7ec2838b
--- /dev/null
+++ b/frontend/src/lib/utils/validateFile.spec.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest';
+import { validateFile, MAX_SIZE_BYTES } from './validateFile';
+
+function makeFile(type: string, size: number): File {
+ return new File(['x'.repeat(Math.min(size, 100))], 'test.file', { type });
+}
+
+describe('validateFile', () => {
+ it('returns null for a valid PDF under 50 MB', () => {
+ const file = makeFile('application/pdf', 1024);
+ expect(validateFile(file)).toBeNull();
+ });
+
+ it('returns null for a valid JPEG', () => {
+ expect(validateFile(makeFile('image/jpeg', 1024))).toBeNull();
+ });
+
+ it('returns null for a valid PNG', () => {
+ expect(validateFile(makeFile('image/png', 1024))).toBeNull();
+ });
+
+ it('returns null for a valid TIFF', () => {
+ expect(validateFile(makeFile('image/tiff', 1024))).toBeNull();
+ });
+
+ it('returns "type" for an unsupported MIME type', () => {
+ const file = makeFile('text/plain', 100);
+ expect(validateFile(file)).toBe('type');
+ });
+
+ it('returns "size" for a file exceeding 50 MB', () => {
+ const oversized = new File(['x'], 'big.pdf', { type: 'application/pdf' });
+ Object.defineProperty(oversized, 'size', { value: MAX_SIZE_BYTES + 1 });
+ expect(validateFile(oversized)).toBe('size');
+ });
+});
diff --git a/frontend/src/lib/utils/validateFile.ts b/frontend/src/lib/utils/validateFile.ts
new file mode 100644
index 00000000..77d8c570
--- /dev/null
+++ b/frontend/src/lib/utils/validateFile.ts
@@ -0,0 +1,10 @@
+export const ALLOWED_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']);
+export const MAX_SIZE_BYTES = 50 * 1024 * 1024;
+
+export type FileValidationError = 'type' | 'size';
+
+export function validateFile(file: File): FileValidationError | null {
+ if (!ALLOWED_TYPES.has(file.type)) return 'type';
+ if (file.size > MAX_SIZE_BYTES) return 'size';
+ return null;
+}
--
2.49.1
From 3f07c4fe5855a835a2bfd0ca9881023cf08adbdf Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 16:18:24 +0200
Subject: [PATCH 18/20] refactor(types): use generated Document type for doc
prop in DocumentEditLayout
Co-Authored-By: Claude Sonnet 4.6
---
.../components/document/DocumentEditLayout.svelte | 15 ++-------------
1 file changed, 2 insertions(+), 13 deletions(-)
diff --git a/frontend/src/lib/components/document/DocumentEditLayout.svelte b/frontend/src/lib/components/document/DocumentEditLayout.svelte
index 04bb203c..2e9eb343 100644
--- a/frontend/src/lib/components/document/DocumentEditLayout.svelte
+++ b/frontend/src/lib/components/document/DocumentEditLayout.svelte
@@ -15,6 +15,7 @@ import type { Tag } from '$lib/components/TagInput.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
+type Doc = components['schemas']['Document'];
let {
doc,
@@ -29,19 +30,7 @@ let {
topbar,
actionbar
}: {
- doc: {
- id: string;
- filePath?: string | null;
- originalFilename?: string | null;
- title?: string | null;
- documentDate?: string | null;
- location?: string | null;
- documentLocation?: string | null;
- summary?: string | null;
- sender?: { id: string; displayName: string } | null;
- receivers?: Person[] | null;
- tags?: Tag[] | null;
- };
+ doc: Doc;
formId: string;
formAction: string;
formError?: string | null;
--
2.49.1
From 921c7dcd7f20aae22b3a1ffb795baa7461b08784 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 16:30:10 +0200
Subject: [PATCH 19/20] test(edit): add tests for handleDelete on the edit page
Covers: button present, confirm dialog opens, form submitted on confirm,
form not submitted on cancel.
Co-Authored-By: Claude Sonnet 4.6
---
.../documents/[id]/edit/page.svelte.spec.ts | 82 +++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 frontend/src/routes/documents/[id]/edit/page.svelte.spec.ts
diff --git a/frontend/src/routes/documents/[id]/edit/page.svelte.spec.ts b/frontend/src/routes/documents/[id]/edit/page.svelte.spec.ts
new file mode 100644
index 00000000..8ddb2614
--- /dev/null
+++ b/frontend/src/routes/documents/[id]/edit/page.svelte.spec.ts
@@ -0,0 +1,82 @@
+import { vi, describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
+import EditPage from './+page.svelte';
+
+afterEach(cleanup);
+
+function makeDocument(overrides: Record = {}) {
+ return {
+ id: 'doc-1',
+ title: 'Test Document',
+ originalFilename: 'test.pdf',
+ status: 'UPLOADED' as const,
+ createdAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ metadataComplete: false,
+ scriptType: 'UNKNOWN' as const,
+ // No filePath — avoids triggering the file loader fetch in tests
+ ...overrides
+ };
+}
+
+describe('Edit page — delete button', () => {
+ it('renders the delete button', async () => {
+ const service = createConfirmService();
+ render(EditPage, {
+ props: { data: { document: makeDocument() }, form: null },
+ context: new Map([[CONFIRM_KEY, service]])
+ });
+ await expect.element(page.getByRole('button', { name: /löschen/i })).toBeInTheDocument();
+ });
+
+ it('opens a confirm dialog when the delete button is clicked', async () => {
+ const service = createConfirmService();
+ render(EditPage, {
+ props: { data: { document: makeDocument() }, form: null },
+ context: new Map([[CONFIRM_KEY, service]])
+ });
+
+ await page.getByRole('button', { name: /löschen/i }).click();
+ // The confirm service should have received an options object (dialog is open)
+ expect(service.options).not.toBeNull();
+ expect(service.options?.destructive).toBe(true);
+ service.settle(false);
+ });
+
+ it('submits the delete form when the user confirms', async () => {
+ const service = createConfirmService();
+ render(EditPage, {
+ props: { data: { document: makeDocument() }, form: null },
+ context: new Map([[CONFIRM_KEY, service]])
+ });
+
+ const deleteForm = document.getElementById('delete-form') as HTMLFormElement;
+ const submitSpy = vi.spyOn(deleteForm, 'requestSubmit').mockImplementation(() => {});
+
+ await page.getByRole('button', { name: /löschen/i }).click();
+ // confirm() has been called synchronously — settle and flush the microtask queue
+ service.settle(true);
+ await Promise.resolve();
+
+ expect(submitSpy).toHaveBeenCalledOnce();
+ });
+
+ it('does NOT submit the delete form when the user cancels', async () => {
+ const service = createConfirmService();
+ render(EditPage, {
+ props: { data: { document: makeDocument() }, form: null },
+ context: new Map([[CONFIRM_KEY, service]])
+ });
+
+ const deleteForm = document.getElementById('delete-form') as HTMLFormElement;
+ const submitSpy = vi.spyOn(deleteForm, 'requestSubmit').mockImplementation(() => {});
+
+ await page.getByRole('button', { name: /löschen/i }).click();
+ service.settle(false);
+ await Promise.resolve();
+
+ expect(submitSpy).not.toHaveBeenCalled();
+ });
+});
--
2.49.1
From 0cdd7d96954de7ab31b026463c35d3655382554a Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sat, 18 Apr 2026 20:12:41 +0200
Subject: [PATCH 20/20] fix(forms): correct required/optional field markers and
divider placement
- Add * to Datum and Absender labels (both are required fields)
- Add required prop to PersonTypeahead to show * in its label
- Move "Optional" divider in DescriptionSection to after Titel (the only
required field), so Tags and Inhalt appear below the divider where they belong
Co-Authored-By: Claude Sonnet 4.6
---
.../src/lib/components/PersonTypeahead.svelte | 4 +++-
.../document/DescriptionSection.svelte | 22 +++++++++----------
.../components/document/WhoWhenSection.svelte | 3 ++-
3 files changed, 16 insertions(+), 13 deletions(-)
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte
index f9bbece4..45ef34c7 100644
--- a/frontend/src/lib/components/PersonTypeahead.svelte
+++ b/frontend/src/lib/components/PersonTypeahead.svelte
@@ -16,6 +16,7 @@ interface Props {
compact?: boolean;
large?: boolean;
autofocus?: boolean;
+ required?: boolean;
restrictToCorrespondentsOf?: string;
onchange?: (value: string) => void;
onfocused?: () => void;
@@ -31,6 +32,7 @@ let {
compact = false,
large = false,
autofocus = false,
+ required = false,
restrictToCorrespondentsOf,
onchange,
onfocused
@@ -114,7 +116,7 @@ function selectPerson(person: Person) {
class={compact
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
: 'block text-sm font-medium text-ink-2'}
- >{label}{label}{#if required}*{/if}
diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte
index f26c96fb..27e7442e 100644
--- a/frontend/src/lib/components/document/DescriptionSection.svelte
+++ b/frontend/src/lib/components/document/DescriptionSection.svelte
@@ -56,14 +56,23 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
{/if}
-
+
+
+
+
{m.label_optional()}
+
+
+
+
{m.form_label_tags()}
t.name).join(',')} />
-
+
-
-
-
-
{m.label_optional()}
-
-
-