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}
+ + +
+
+ Optional +
+
+ + +
+ + +

{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
- Optional + Optional
-- 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()} + + +
+ + {m.btn_cancel()} + + + + + +
+ {/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 @@ - - -
- - -
- -
- - -
- - -
- - - {m.btn_cancel()} - -
-
-
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} + + +
-- 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()} -
-
-