From 332b5b3c40756c11861dc1630b801f33e1841ddd Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 09:59:59 +0100 Subject: [PATCH 01/13] feat(upload): add POST /api/documents/quick-upload endpoint for bulk file upload Adds a new multipart endpoint that accepts multiple files and creates one document per file without requiring any form metadata. Each document gets title = filename-without-extension and status = UPLOADED. - Fix storeDocument() to strip the file extension from the document title - Validate content type (PDF/JPEG/PNG/TIFF) server-side; unsupported files are skipped and returned as per-file errors in QuickUploadResult - Tests cover 401/403 auth, success path, and unsupported file type Closes #66 (backend part) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 36 +++++++++++++++ .../service/DocumentService.java | 8 +++- .../controller/DocumentControllerTest.java | 45 +++++++++++++++++++ .../service/DocumentServiceTest.java | 38 ++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) 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 f23f465b..a405577d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller; import java.io.IOException; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; @@ -103,6 +105,40 @@ public class DocumentController { } } + // --- QUICK UPLOAD --- + + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "application/pdf", "image/jpeg", "image/png", "image/tiff"); + + public record QuickUploadResult(List created, List errors) {} + + @PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RequirePermission(Permission.WRITE_ALL) + public QuickUploadResult quickUpload( + @RequestPart(value = "files", required = false) List files) { + List created = new ArrayList<>(); + List errors = new ArrayList<>(); + + if (files == null || files.isEmpty()) { + return new QuickUploadResult(created, errors); + } + + for (MultipartFile file : files) { + if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { + errors.add(file.getOriginalFilename() + ": unsupported file type"); + continue; + } + try { + created.add(documentService.storeDocument(file)); + } catch (Exception e) { + errors.add(file.getOriginalFilename() + ": " + e.getMessage()); + log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage()); + } + } + + return new QuickUploadResult(created, errors); + } + @GetMapping("/search") public ResponseEntity> search( @RequestParam(required = false) String q, 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 a79a8f22..45cfd2da 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -61,7 +61,7 @@ public class DocumentService { } else { document = Document.builder() .originalFilename(originalFilename) - .title(originalFilename) + .title(stripExtension(originalFilename)) .status(DocumentStatus.UPLOADED) .build(); } @@ -307,6 +307,12 @@ public class DocumentService { // ─── private helpers ────────────────────────────────────────────────────── + private static String stripExtension(String filename) { + if (filename == null) return null; + int dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.substring(0, dot) : filename; + } + private static String sha256Hex(byte[] bytes) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); 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 545e98f3..0f64fddc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -23,6 +23,7 @@ import java.util.Collections; import java.util.List; import java.util.UUID; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -121,6 +122,50 @@ class DocumentControllerTest { .andExpect(status().isOk()); } + // ─── POST /api/documents/quick-upload ──────────────────────────────────── + + @Test + void quickUpload_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(multipart("/api/documents/quick-upload")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void quickUpload_returns403_whenMissingWritePermission() throws Exception { + mockMvc.perform(multipart("/api/documents/quick-upload")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_returns200_withValidPdfFile() throws Exception { + Document doc = Document.builder() + .id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build(); + when(documentService.storeDocument(any())).thenReturn(doc); + + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.created[0].title").value("scan001")) + .andExpect(jsonPath("$.errors").isEmpty()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception { + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("files", "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1}); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.created").isEmpty()) + .andExpect(jsonPath("$.errors[0]").value(containsString("report.docx"))); + } + // ─── GET /api/documents/{id}/versions ──────────────────────────────────── @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 b6fc3dea..36e7c2eb 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -212,6 +212,44 @@ class DocumentServiceTest { verify(documentVersionService).recordVersion(any(Document.class)); } + // ─── storeDocument ─────────────────────────────────────────────────────── + + @Test + void storeDocument_setsTitle_withoutFileExtension_forNewDocument() throws Exception { + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1}); + FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123"); + Document saved = Document.builder().id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build(); + + when(documentRepository.findByOriginalFilename("scan001.pdf")).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenReturn(saved); + when(fileService.uploadFile(any(), any())).thenReturn(uploadResult); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Document.class); + documentService.storeDocument(file); + + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().getTitle()).isEqualTo("scan001"); + } + + @Test + void storeDocument_preservesExistingTitle_whenPlaceholderAlreadyExists() throws Exception { + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1}); + FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123"); + Document placeholder = Document.builder() + .id(UUID.randomUUID()).title("Brief an Oma").originalFilename("scan001.pdf") + .status(org.raddatz.familienarchiv.model.DocumentStatus.PLACEHOLDER).build(); + + when(documentRepository.findByOriginalFilename("scan001.pdf")).thenReturn(Optional.of(placeholder)); + when(documentRepository.save(any())).thenReturn(placeholder); + when(fileService.uploadFile(any(), any())).thenReturn(uploadResult); + + documentService.storeDocument(file); + + assertThat(placeholder.getTitle()).isEqualTo("Brief an Oma"); + } + // ─── backfillFileHashes ─────────────────────────────────────────────────── @Test -- 2.49.1 From bbfef9a22d1c8259f2fc8acd252f0f3eeeda1994 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 10:00:19 +0100 Subject: [PATCH 02/13] feat(upload): add drag-and-drop bulk upload zone to home page Adds a compact, unobtrusive drop zone between the search card and the document list. Only visible to users with WRITE_ALL permission. - Drag-and-drop or click-to-select multiple files at once - Client-side MIME type validation with per-file error messages - POSTs to /api/documents/quick-upload; refreshes list via invalidateAll() - Inline feedback: success count + per-file errors - i18n keys added to de/en/es message files Closes #66 (frontend part) Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 7 +- frontend/messages/en.json | 7 +- frontend/messages/es.json | 7 +- frontend/src/routes/+page.svelte | 129 ++++++++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5d29f488..0c8f9838 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -265,5 +265,10 @@ "doc_panel_annotation_thread_title": "Annotation", "doc_panel_discussion_annotation_tab": "Annotation · Seite {page}", "pdf_annotations_show": "Annotierungen anzeigen", - "pdf_annotations_hide": "Annotierungen verbergen" + "pdf_annotations_hide": "Annotierungen verbergen", + "upload_drop_hint": "Dateien ablegen oder auswählen", + "upload_accepted_types": "PDF, JPEG, PNG, TIFF", + "upload_success": "{count} Dokument(e) erstellt", + "upload_invalid_type": "{filename}: Dateiformat nicht unterstützt", + "upload_error": "Fehler beim Hochladen von {filename}" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 94bf692f..eb5ba31a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -265,5 +265,10 @@ "doc_panel_annotation_thread_title": "Annotation", "doc_panel_discussion_annotation_tab": "Annotation · Page {page}", "pdf_annotations_show": "Show annotations", - "pdf_annotations_hide": "Hide annotations" + "pdf_annotations_hide": "Hide annotations", + "upload_drop_hint": "Drop files or click to select", + "upload_accepted_types": "PDF, JPEG, PNG, TIFF", + "upload_success": "{count} document(s) created", + "upload_invalid_type": "{filename}: unsupported file format", + "upload_error": "Error uploading {filename}" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index e0a46eef..dfb5ec70 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -265,5 +265,10 @@ "doc_panel_annotation_thread_title": "Anotación", "doc_panel_discussion_annotation_tab": "Anotación · Página {page}", "pdf_annotations_show": "Mostrar anotaciones", - "pdf_annotations_hide": "Ocultar anotaciones" + "pdf_annotations_hide": "Ocultar anotaciones", + "upload_drop_hint": "Soltar archivos o hacer clic para seleccionar", + "upload_accepted_types": "PDF, JPEG, PNG, TIFF", + "upload_success": "{count} documento(s) creado(s)", + "upload_invalid_type": "{filename}: formato de archivo no admitido", + "upload_error": "Error al subir {filename}" } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index cbe28932..7b9ab16e 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,6 +1,6 @@