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..ba34092b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -129,11 +129,25 @@ public class DocumentController { return ResponseEntity.noContent().build(); } - // --- QUICK UPLOAD --- + // --- 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) { + 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 --- + 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 2f074e48..b2d14d87 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,28 @@ public class DocumentService { return documentRepository.save(doc); } + @Transactional + 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; + 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()); + 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..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; @@ -563,6 +565,63 @@ 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")); + } + + @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); + } } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5417f747..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.", @@ -52,6 +53,8 @@ "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", + "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 b2e76304..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.", @@ -52,6 +53,8 @@ "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", + "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 96f9efc8..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.", @@ -52,6 +53,8 @@ "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", + "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/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 985cdd42..45ef34c7 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -15,6 +15,8 @@ interface Props { placeholder?: string; compact?: boolean; large?: boolean; + autofocus?: boolean; + required?: boolean; restrictToCorrespondentsOf?: string; onchange?: (value: string) => void; onfocused?: () => void; @@ -29,6 +31,8 @@ let { placeholder, compact = false, large = false, + autofocus = false, + required = false, restrictToCorrespondentsOf, onchange, onfocused @@ -112,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} @@ -121,6 +125,7 @@ function selectPerson(person: Person) { type="text" id="{name}-search" autocomplete="off" + autofocus={autofocus} bind:value={searchTerm} oninput={handleInput} onfocus={handleFocus} diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte index ac5b7cd0..27e7442e 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.label_optional()} +
+
+ + +
+

{m.form_label_tags()}

+ + t.name).join(',')} /> +
+ + +
+ + +
+ +

{m.form_helper_archive_location()}

- - -
-

{m.form_label_tags()}

- - t.name).join(',')} /> -
- - -
- - -
diff --git a/frontend/src/lib/components/document/DocumentEditLayout.svelte b/frontend/src/lib/components/document/DocumentEditLayout.svelte new file mode 100644 index 00000000..2e9eb343 --- /dev/null +++ b/frontend/src/lib/components/document/DocumentEditLayout.svelte @@ -0,0 +1,222 @@ + + +
+ +
+ {@render topbar()} +
+ + +
+ {m.label_required_fields()} +
+
+
+ {requiredFilled} / 3 +
+ + +
+ +
+ {#if !doc.filePath} + + {:else} + +
+ +
+
+ {}} + /> +
+ {/if} +
+ + +
+ {#if formError} +
+ {formError} +
+ {/if} + +
+ + + + + +
+ {@render actionbar()} +
+
+
+
diff --git a/frontend/src/lib/components/document/UploadZone.svelte b/frontend/src/lib/components/document/UploadZone.svelte new file mode 100644 index 00000000..10c8c574 --- /dev/null +++ b/frontend/src/lib/components/document/UploadZone.svelte @@ -0,0 +1,115 @@ + + +
+ {#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.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(); + }); +}); 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..553d8638 --- /dev/null +++ b/frontend/src/lib/components/document/UploadZone.svelte.test.ts @@ -0,0 +1,115 @@ +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 } + }); + // 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(); + }); + }); + + 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('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, { + 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(); + 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/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index d86a6743..b5d85a56 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -4,16 +4,14 @@ 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(''), selectedReceivers = $bindable([]), + dateIso = $bindable(''), initialDateIso = '', initialLocation = '', initialSenderName = '', @@ -22,6 +20,7 @@ let { }: { senderId?: string; selectedReceivers?: Person[]; + dateIso?: string; initialDateIso?: string; initialLocation?: string; initialSenderName?: string; @@ -30,7 +29,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,10 +56,10 @@ $effect(() => {
- +
{m.form_label_date()}* { 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 +80,26 @@ $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()}

- -
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; +} 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; +} 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/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(); + }); +}); 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); diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte index 783326bc..81d72d49 100644 --- a/frontend/src/routes/enrich/[id]/+page.svelte +++ b/frontend/src/routes/enrich/[id]/+page.svelte @@ -1,50 +1,19 @@ {doc.title || doc.originalFilename || 'Dokument'} — Anreicherung - + {/snippet} + + +
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%); + } +}