From ae445a78aecbe484c57318a8a03b78bf89407f31 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 16:01:21 +0200 Subject: [PATCH 01/46] feat(documents): extend quick-upload with optional batch metadata part - Add DocumentBatchMetadataDTO (titles, senderId, receiverIds, documentDate, location, tags, metadataComplete) - Add BATCH_TOO_LARGE to ErrorCode - Extend quickUpload to accept optional @RequestPart("metadata"); dispatches to storeDocumentWithBatchMetadata when present - Cap batch at 50 files/request; reject 400 when titles.size > files.size - Add DocumentService.storeDocumentWithBatchMetadata applying shared fields + index-based titles to both created and updated docs - Raise max-request-size to 500MB (10-file chunk at max per-file size) - Add structured SLF4J logging for every quickUpload call Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 22 ++- .../dto/DocumentBatchMetadataDTO.java | 18 +++ .../familienarchiv/exception/ErrorCode.java | 2 + .../service/DocumentService.java | 38 +++++ backend/src/main/resources/application.yaml | 3 +- .../controller/DocumentControllerTest.java | 133 +++++++++++++++++- 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java 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 8b17a0ef..cd03a22f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -18,6 +18,7 @@ import jakarta.validation.constraints.Min; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.validation.annotation.Validated; +import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.TagOperator; @@ -193,6 +194,7 @@ public class DocumentController { @RequirePermission(Permission.WRITE_ALL) public QuickUploadResult quickUpload( @RequestPart(value = "files", required = false) List files, + @RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata, Authentication authentication) { List created = new ArrayList<>(); List updated = new ArrayList<>(); @@ -202,14 +204,26 @@ public class DocumentController { return new QuickUploadResult(created, updated, errors); } + if (files.size() > 50) { + throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"); + } + if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > files.size()) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"); + } + UUID actorId = requireUserId(authentication); - for (MultipartFile file : files) { + long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum(); + + for (int i = 0; i < files.size(); i++) { + MultipartFile file = files.get(i); if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE")); continue; } try { - DocumentService.StoreResult result = documentService.storeDocument(file, actorId); + DocumentService.StoreResult result = metadata != null + ? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId) + : documentService.storeDocument(file, actorId); if (result.isNew()) { created.add(result.document()); } else { @@ -221,6 +235,10 @@ public class DocumentController { } } + log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}", + actorId, files.size(), totalBytes, metadata != null, + created.size(), updated.size(), errors.size()); + return new QuickUploadResult(created, updated, errors); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java new file mode 100644 index 00000000..93e17fbf --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Data +public class DocumentBatchMetadataDTO { + private List titles; + private UUID senderId; + private List receiverIds; + private LocalDate documentDate; + private String location; + private String tags; + private Boolean metadataComplete; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 85cd7d2c..686c8627 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -109,6 +109,8 @@ public enum ErrorCode { // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, + /** Batch upload exceeds the maximum allowed file count per request. 400 */ + BATCH_TOO_LARGE, /** An unexpected server-side error occurred. 500 */ INTERNAL_ERROR, } 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 fbdec954..36575f28 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -7,6 +7,7 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; import org.raddatz.familienarchiv.dto.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; @@ -132,6 +133,43 @@ public class DocumentService { return new StoreResult(saved, isNew); } + @Transactional + public StoreResult storeDocumentWithBatchMetadata( + MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException { + StoreResult base = storeDocument(file, actorId); + Document doc = base.document(); + + if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) { + doc.setTitle(metadata.getTitles().get(fileIndex)); + } + if (metadata.getSenderId() != null) { + doc.setSender(personService.getById(metadata.getSenderId())); + } + if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) { + doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds()))); + } + if (metadata.getDocumentDate() != null) { + doc.setDocumentDate(metadata.getDocumentDate()); + } + if (metadata.getLocation() != null) { + doc.setLocation(metadata.getLocation()); + } + if (metadata.getMetadataComplete() != null) { + doc.setMetadataComplete(metadata.getMetadataComplete()); + } + if (metadata.getTags() != null && !metadata.getTags().isBlank()) { + List tagNames = Arrays.stream(metadata.getTags().split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).toList(); + UUID docId = doc.getId(); + updateDocumentTags(docId, tagNames); + doc = documentRepository.findById(docId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId)); + } + + Document saved = documentRepository.save(doc); + return new StoreResult(saved, base.isNew()); + } + @Transactional public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException { String filename = (file != null && !file.isEmpty()) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index d9bbe9d0..1cdd7673 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -23,7 +23,8 @@ spring: servlet: multipart: max-file-size: 50MB - max-request-size: 50MB + max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317 + file-size-threshold: 2KB mail: host: ${MAIL_HOST:} 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 eb4c6873..1981b1b2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -1,15 +1,17 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentVersion; +import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.security.PermissionAspect; -import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.DocumentVersionService; @@ -766,4 +768,133 @@ class DocumentControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.editorName").value("Otto")); } + + // ─── POST /api/documents/quick-upload — metadata part ──────────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception { + UUID senderId = UUID.randomUUID(); + Person sender = Person.builder().id(senderId).lastName("Müller").build(); + + Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build(); + Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build(); + Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build(); + + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any())) + .thenReturn(new DocumentService.StoreResult(doc1, true)); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any())) + .thenReturn(new DocumentService.StoreResult(doc2, true)); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any())) + .thenReturn(new DocumentService.StoreResult(doc3, true)); + + org.springframework.mock.web.MockMultipartFile f1 = + new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f2 = + new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f3 = + new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + ("{\"senderId\":\"" + senderId + "\"}").getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.created.length()").value(3)) + .andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString())) + .andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString())) + .andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString())) + .andExpect(jsonPath("$.updated").isEmpty()) + .andExpect(jsonPath("$.errors").isEmpty()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception { + UUID senderId = UUID.randomUUID(); + Person sender = Person.builder().id(senderId).lastName("Müller").build(); + Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build(); + + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any())) + .thenReturn(new DocumentService.StoreResult(existing, false)); + + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + ("{\"senderId\":\"" + senderId + "\"}").getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.created").isEmpty()) + .andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString())) + .andExpect(jsonPath("$.errors").isEmpty()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception { + Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build(); + Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build(); + Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build(); + + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any())) + .thenReturn(new DocumentService.StoreResult(docA, true)); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any())) + .thenReturn(new DocumentService.StoreResult(docB, true)); + when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any())) + .thenReturn(new DocumentService.StoreResult(docC, true)); + + org.springframework.mock.web.MockMultipartFile f1 = + new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f2 = + new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f3 = + new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + "{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.created[0].title").value("Alpha")) + .andExpect(jsonPath("$.created[1].title").value("Beta")) + .andExpect(jsonPath("$.created[2].title").value("Gamma")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + + org.springframework.mock.web.MockMultipartFile f1 = + new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile f2 = + new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + "{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_returns400_whenBatchExceedsCap() throws Exception { + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + + var builder = multipart("/api/documents/quick-upload"); + for (int i = 0; i < 51; i++) { + builder.file(new org.springframework.mock.web.MockMultipartFile( + "files", "f" + i + ".pdf", "application/pdf", new byte[]{1})); + } + + mockMvc.perform(builder) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE")); + } } -- 2.49.1 From f86105a1bea78e209817c148b6c3792a2a9ee902 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 16:18:51 +0200 Subject: [PATCH 02/46] feat(i18n): add BATCH_TOO_LARGE error code + 16 bulk-upload Paraglide keys Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 19 ++++++++++++++++++- frontend/messages/en.json | 19 ++++++++++++++++++- frontend/messages/es.json | 19 ++++++++++++++++++- frontend/src/lib/errors.ts | 3 +++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 1b90d913..8bd62c50 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -811,6 +811,7 @@ "pagination_next": "Weiter", "pagination_page_of": "Seite {page} von {total}", "pagination_nav_label": "Seitennavigation", +<<<<<<< HEAD "common_opens_new_tab": "(öffnet in neuem Tab)", @@ -850,5 +851,21 @@ "richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche", "richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung", "richtlinien_closing_title": "Fehlt eine Regel?", - "richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen." + "richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.", + "error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.", + "bulk_drop_hint": "Eine oder mehrere Dateien ablegen", + "bulk_drop_sub": "PDF, JPEG, PNG oder TIFF · bis zu 50 MB pro Datei", + "bulk_count_pill": "{count} werden erstellt", + "bulk_save_cta": "{count, plural, one {Speichern →} other {{count} speichern →}}", + "bulk_discard_all": "Alle verwerfen", + "bulk_add_more": "Weitere hinzufügen", + "bulk_scope_per_file_label": "Nur diese Datei", + "bulk_scope_shared_label": "Gilt für alle {count}", + "bulk_title_suggested_hint": "Vorschlag aus Dateiname — zum Bearbeiten anklicken", + "bulk_switcher_prev": "Vorherige Datei", + "bulk_switcher_next": "Nächste Datei", + "bulk_file_error_chip_label": "Fehler beim Hochladen", + "bulk_upload_progress": "{done} von {total} hochgeladen", + "bulk_partial_success": "{created} erstellt, {failed} fehlgeschlagen", + "bulk_all_failed": "Alle Uploads fehlgeschlagen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f5862f0f..afc3ed73 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -811,6 +811,7 @@ "pagination_next": "Next", "pagination_page_of": "Page {page} of {total}", "pagination_nav_label": "Pagination", +<<<<<<< HEAD "common_opens_new_tab": "(opens in new tab)", @@ -850,5 +851,21 @@ "richtlinien_klaer_umbrueche": "Original line breaks", "richtlinien_klaer_caps": "Old capitalisation", "richtlinien_closing_title": "Missing a rule?", - "richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering." + "richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.", + "error_batch_too_large": "Too many files at once — please upload in smaller batches.", + "bulk_drop_hint": "Drop one or more files here", + "bulk_drop_sub": "PDF, JPEG, PNG or TIFF · up to 50 MB per file", + "bulk_count_pill": "{count} will be created", + "bulk_save_cta": "{count, plural, one {Save →} other {Save {count} →}}", + "bulk_discard_all": "Discard all", + "bulk_add_more": "Add more", + "bulk_scope_per_file_label": "This file only", + "bulk_scope_shared_label": "Applies to all {count}", + "bulk_title_suggested_hint": "Suggested from filename — click to edit", + "bulk_switcher_prev": "Previous file", + "bulk_switcher_next": "Next file", + "bulk_file_error_chip_label": "Upload failed", + "bulk_upload_progress": "{done} of {total} uploaded", + "bulk_partial_success": "{created} created, {failed} failed", + "bulk_all_failed": "All uploads failed" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index c949ce2c..cf1f27e4 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -811,6 +811,7 @@ "pagination_next": "Siguiente", "pagination_page_of": "Página {page} de {total}", "pagination_nav_label": "Paginación", +<<<<<<< HEAD "common_opens_new_tab": "(abre en pestaña nueva)", @@ -850,5 +851,21 @@ "richtlinien_klaer_umbrueche": "Saltos de línea originales", "richtlinien_klaer_caps": "Mayúsculas antiguas", "richtlinien_closing_title": "¿Falta una regla?", - "richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar." + "richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.", + "error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.", + "bulk_drop_hint": "Suelta uno o varios archivos aquí", + "bulk_drop_sub": "PDF, JPEG, PNG o TIFF · hasta 50 MB por archivo", + "bulk_count_pill": "Se crearán {count}", + "bulk_save_cta": "{count, plural, one {Guardar →} other {Guardar {count} →}}", + "bulk_discard_all": "Descartar todo", + "bulk_add_more": "Añadir más", + "bulk_scope_per_file_label": "Solo este archivo", + "bulk_scope_shared_label": "Para todos los {count}", + "bulk_title_suggested_hint": "Sugerencia del nombre de archivo — haz clic para editar", + "bulk_switcher_prev": "Archivo anterior", + "bulk_switcher_next": "Archivo siguiente", + "bulk_file_error_chip_label": "Error al subir", + "bulk_upload_progress": "{done} de {total} subidos", + "bulk_partial_success": "{created} creados, {failed} fallidos", + "bulk_all_failed": "Todos los uploads fallaron" } diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index d9ec385d..1a1d1551 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -41,6 +41,7 @@ export type ErrorCode = | 'UNAUTHORIZED' | 'FORBIDDEN' | 'VALIDATION_ERROR' + | 'BATCH_TOO_LARGE' | 'INTERNAL_ERROR'; export interface BackendError { @@ -139,6 +140,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_forbidden(); case 'VALIDATION_ERROR': return m.error_validation_error(); + case 'BATCH_TOO_LARGE': + return m.error_batch_too_large(); default: return m.error_internal_error(); } -- 2.49.1 From 4248d8af72fec850d5c022b427c19fd98323ce22 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 16:30:12 +0200 Subject: [PATCH 03/46] feat(bulk-upload): add bulkTitleFromFilename utility Converts a raw filename into a human-readable title candidate by stripping the extension and replacing underscore/hyphen runs with spaces. Reuses the existing stripExtension() helper. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/utils/filename.spec.ts | 20 +++++++++++++++++++- frontend/src/lib/utils/filename.ts | 4 ++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/utils/filename.spec.ts b/frontend/src/lib/utils/filename.spec.ts index 297c8a1e..77b3fbdd 100644 --- a/frontend/src/lib/utils/filename.spec.ts +++ b/frontend/src/lib/utils/filename.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseFilename, stripExtension } from './filename'; +import { parseFilename, stripExtension, bulkTitleFromFilename } from './filename'; describe('parseFilename', () => { describe('date-first patterns', () => { @@ -86,6 +86,24 @@ describe('parseFilename', () => { }); }); +describe('bulkTitleFromFilename', () => { + it('replaces underscores with spaces', () => { + expect(bulkTitleFromFilename('hello_world.pdf')).toBe('hello world'); + }); + + it('replaces hyphens with spaces', () => { + expect(bulkTitleFromFilename('2024-01-01_Max.pdf')).toBe('2024 01 01 Max'); + }); + + it('collapses multiple separators', () => { + expect(bulkTitleFromFilename('foo__bar--baz.pdf')).toBe('foo bar baz'); + }); + + it('strips extension', () => { + expect(bulkTitleFromFilename('document.pdf')).toBe('document'); + }); +}); + describe('stripExtension', () => { it('removes the extension', () => { expect(stripExtension('document.pdf')).toBe('document'); diff --git a/frontend/src/lib/utils/filename.ts b/frontend/src/lib/utils/filename.ts index 101fdd1c..582060aa 100644 --- a/frontend/src/lib/utils/filename.ts +++ b/frontend/src/lib/utils/filename.ts @@ -81,3 +81,7 @@ export function parseFilename(filename: string): FilenameParseResult { export function stripExtension(filename: string): string { return filename.replace(/\.[^/.]+$/, ''); } + +export function bulkTitleFromFilename(filename: string): string { + return stripExtension(filename).replace(/[_-]+/g, ' ').trim(); +} -- 2.49.1 From 22bba5cfcd30e973b9471263c3bbc2e5507b981b Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 16:40:04 +0200 Subject: [PATCH 04/46] feat(bulk-upload): add BulkDropZone component Full-panel drop target that supports multi-file selection via drag-and-drop or file picker. Fires onFilesAdded callback with the full File array. Co-Authored-By: Claude Sonnet 4.6 --- .../components/document/BulkDropZone.svelte | 64 +++++++++++++++++++ .../document/BulkDropZone.svelte.spec.ts | 39 +++++++++++ 2 files changed, 103 insertions(+) create mode 100644 frontend/src/lib/components/document/BulkDropZone.svelte create mode 100644 frontend/src/lib/components/document/BulkDropZone.svelte.spec.ts diff --git a/frontend/src/lib/components/document/BulkDropZone.svelte b/frontend/src/lib/components/document/BulkDropZone.svelte new file mode 100644 index 00000000..065159e3 --- /dev/null +++ b/frontend/src/lib/components/document/BulkDropZone.svelte @@ -0,0 +1,64 @@ + + +
+
{ + e.preventDefault(); + isDragging = true; + }} + ondragleave={() => (isDragging = false)} + ondrop={(e) => { + e.preventDefault(); + isDragging = false; + if (e.dataTransfer && e.dataTransfer.files.length > 0) { + onFilesAdded(Array.from(e.dataTransfer.files)); + } + }} + > +
+ +
+

PDF-Dateien hier ablegen

+

oder

+ +
+
diff --git a/frontend/src/lib/components/document/BulkDropZone.svelte.spec.ts b/frontend/src/lib/components/document/BulkDropZone.svelte.spec.ts new file mode 100644 index 00000000..139933b6 --- /dev/null +++ b/frontend/src/lib/components/document/BulkDropZone.svelte.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import BulkDropZone from './BulkDropZone.svelte'; + +afterEach(cleanup); + +describe('BulkDropZone', () => { + it('file input has multiple attribute', async () => { + const { container } = render(BulkDropZone, { onFilesAdded: vi.fn() }); + const input = container.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + expect(input?.hasAttribute('multiple')).toBe(true); + }); + + it('fires onFilesAdded with selected files when 3 files are picked via input', async () => { + const onFilesAdded = vi.fn(); + render(BulkDropZone, { onFilesAdded }); + + const files = [ + new File(['a'], 'a.pdf', { type: 'application/pdf' }), + new File(['b'], 'b.pdf', { type: 'application/pdf' }), + new File(['c'], 'c.pdf', { type: 'application/pdf' }) + ]; + + const input = page.getByRole('button', { name: /Dateien auswählen/i }); + await userEvent.upload(input, files); + + expect(onFilesAdded).toHaveBeenCalledOnce(); + const received: File[] = onFilesAdded.mock.calls[0][0]; + expect(received).toHaveLength(3); + expect(received.map((f) => f.name)).toEqual(['a.pdf', 'b.pdf', 'c.pdf']); + }); + + it('shows drop hint text', async () => { + render(BulkDropZone, { onFilesAdded: vi.fn() }); + await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument(); + }); +}); -- 2.49.1 From 1f1b7aeab5a9af4383e472a6cb4b3f0a082dcc89 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 17:40:33 +0200 Subject: [PATCH 05/46] feat(bulk-upload): add FileSwitcherStrip component Horizontal chip strip for switching between files in a bulk upload session. Supports keyboard navigation (arrow keys cycle within the strip), error state chips, and onSelect/onRemove callbacks. Co-Authored-By: Claude Sonnet 4.6 --- .../document/FileSwitcherStrip.svelte | 91 +++++++++++++++++ .../document/FileSwitcherStrip.svelte.spec.ts | 97 +++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 frontend/src/lib/components/document/FileSwitcherStrip.svelte create mode 100644 frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte b/frontend/src/lib/components/document/FileSwitcherStrip.svelte new file mode 100644 index 00000000..e7badb4d --- /dev/null +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte @@ -0,0 +1,91 @@ + + +
    + {#each files as entry (entry.id)} +
  • + + +
  • + {/each} +
diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts b/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts new file mode 100644 index 00000000..86968e24 --- /dev/null +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import FileSwitcherStrip from './FileSwitcherStrip.svelte'; + +afterEach(cleanup); + +export interface FileEntry { + id: string; + file: File; + title: string; + status: 'idle' | 'error'; +} + +function makeFiles(n: number): FileEntry[] { + return Array.from({ length: n }, (_, i) => ({ + id: `id-${i}`, + file: new File([''], `file${i}.pdf`), + title: `File ${i}`, + status: 'idle' as const + })); +} + +describe('FileSwitcherStrip', () => { + it('renders N chips for N files', async () => { + const files = makeFiles(4); + render(FileSwitcherStrip, { + files, + activeId: files[0].id, + onSelect: vi.fn(), + onRemove: vi.fn() + }); + const chips = page.getByRole('listitem'); + await expect.element(chips.nth(0)).toBeInTheDocument(); + await expect.element(chips.nth(3)).toBeInTheDocument(); + }); + + it('active chip has aria-current="true"', async () => { + const files = makeFiles(3); + const { container } = render(FileSwitcherStrip, { + files, + activeId: files[1].id, + onSelect: vi.fn(), + onRemove: vi.fn() + }); + const activeBtn = container.querySelector('[aria-current="true"]'); + expect(activeBtn).not.toBeNull(); + expect(activeBtn?.textContent).toContain('File 1'); + }); + + it('clicking a chip fires onSelect with its id', async () => { + const files = makeFiles(3); + const onSelect = vi.fn(); + const { container } = render(FileSwitcherStrip, { + files, + activeId: files[0].id, + onSelect, + onRemove: vi.fn() + }); + const chip = container.querySelector('[data-chip-id="id-2"]') as HTMLElement; + expect(chip).not.toBeNull(); + chip.click(); + expect(onSelect).toHaveBeenCalledWith('id-2'); + }); + + it('error chip has aria-label containing warning indicator', async () => { + const files: FileEntry[] = [ + { id: 'e1', file: new File([''], 'bad.pdf'), title: 'Bad file', status: 'error' } + ]; + const { container } = render(FileSwitcherStrip, { + files, + activeId: 'e1', + onSelect: vi.fn(), + onRemove: vi.fn() + }); + const errBtn = container.querySelector('[data-status="error"]'); + expect(errBtn).not.toBeNull(); + }); + + it('ArrowRight moves focus to next chip without leaving strip', async () => { + const files = makeFiles(3); + const { container } = render(FileSwitcherStrip, { + files, + activeId: files[0].id, + onSelect: vi.fn(), + onRemove: vi.fn() + }); + const firstBtn = container.querySelectorAll('[role="button"]')[0] as HTMLElement; + firstBtn.focus(); + await userEvent.keyboard('{ArrowRight}'); + const focused = document.activeElement; + expect(focused).not.toBe(firstBtn); + // The new focused element should still be inside the strip + const strip = container.querySelector('[data-testid="file-switcher-strip"]'); + expect(strip?.contains(focused)).toBe(true); + }); +}); -- 2.49.1 From 6d5fb9d8c87af7a6febcbd45600c5e663c637ebf Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 17:43:10 +0200 Subject: [PATCH 06/46] feat(bulk-upload): add ScopeCard component Card container with two variants: per-file (mint tint) and shared (neutral with file-count badge). Used to visually separate per-file vs shared metadata sections in the bulk upload layout. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/document/ScopeCard.svelte | 35 +++++++++++++++++++ .../document/ScopeCard.svelte.spec.ts | 32 +++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 frontend/src/lib/components/document/ScopeCard.svelte create mode 100644 frontend/src/lib/components/document/ScopeCard.svelte.spec.ts diff --git a/frontend/src/lib/components/document/ScopeCard.svelte b/frontend/src/lib/components/document/ScopeCard.svelte new file mode 100644 index 00000000..d0e7c5c7 --- /dev/null +++ b/frontend/src/lib/components/document/ScopeCard.svelte @@ -0,0 +1,35 @@ + + +
+ {#if variant === 'shared'} +
+ + Gilt für alle Dateien + + + {count} + +
+ {:else} +

Diese Datei

+ {/if} + {@render children?.()} +
diff --git a/frontend/src/lib/components/document/ScopeCard.svelte.spec.ts b/frontend/src/lib/components/document/ScopeCard.svelte.spec.ts new file mode 100644 index 00000000..51c4ae1b --- /dev/null +++ b/frontend/src/lib/components/document/ScopeCard.svelte.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ScopeCard from './ScopeCard.svelte'; + +afterEach(cleanup); + +describe('ScopeCard', () => { + it('per-file variant has mint background class', async () => { + const { container } = render(ScopeCard, { variant: 'per-file', count: 1 }); + const card = container.querySelector('[data-testid="scope-card"]'); + expect(card?.className).toMatch(/brand-mint/); + }); + + it('shared variant does not have mint background', async () => { + const { container } = render(ScopeCard, { variant: 'shared', count: 3 }); + const card = container.querySelector('[data-testid="scope-card"]'); + expect(card?.className).not.toMatch(/bg-brand-mint/); + }); + + it('shared variant renders count badge with file count', async () => { + render(ScopeCard, { variant: 'shared', count: 5 }); + await expect.element(page.getByText('5')).toBeInTheDocument(); + }); + + it('per-file variant renders slot content', async () => { + // ScopeCard is a container — verify it renders children + render(ScopeCard, { variant: 'per-file', count: 1 }); + const card = await page.getByTestId('scope-card'); + await expect.element(card).toBeInTheDocument(); + }); +}); -- 2.49.1 From edd96b05fe5091128503b89f221a14e8933c7c43 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 17:49:23 +0200 Subject: [PATCH 07/46] feat(bulk-upload): add UploadSaveBar component + fix bulk_save_cta message Save bar with sticky positioning, a determinate progress bar while uploading chunks, plural save CTA, and a destructive discard link. Replaces broken ICU plural in bulk_save_cta with two-key approach (bulk_save_cta_one / bulk_save_cta) since Paraglide 2.5 does not support ICU plural syntax. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 3 +- frontend/messages/en.json | 3 +- frontend/messages/es.json | 3 +- .../components/document/UploadSaveBar.svelte | 39 +++++++++++++++ .../document/UploadSaveBar.svelte.spec.ts | 48 +++++++++++++++++++ 5 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/components/document/UploadSaveBar.svelte create mode 100644 frontend/src/lib/components/document/UploadSaveBar.svelte.spec.ts diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8bd62c50..479238c1 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -856,7 +856,8 @@ "bulk_drop_hint": "Eine oder mehrere Dateien ablegen", "bulk_drop_sub": "PDF, JPEG, PNG oder TIFF · bis zu 50 MB pro Datei", "bulk_count_pill": "{count} werden erstellt", - "bulk_save_cta": "{count, plural, one {Speichern →} other {{count} speichern →}}", + "bulk_save_cta_one": "Speichern →", + "bulk_save_cta": "{count} speichern →", "bulk_discard_all": "Alle verwerfen", "bulk_add_more": "Weitere hinzufügen", "bulk_scope_per_file_label": "Nur diese Datei", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index afc3ed73..78e107e4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -856,7 +856,8 @@ "bulk_drop_hint": "Drop one or more files here", "bulk_drop_sub": "PDF, JPEG, PNG or TIFF · up to 50 MB per file", "bulk_count_pill": "{count} will be created", - "bulk_save_cta": "{count, plural, one {Save →} other {Save {count} →}}", + "bulk_save_cta_one": "Save →", + "bulk_save_cta": "Save {count} →", "bulk_discard_all": "Discard all", "bulk_add_more": "Add more", "bulk_scope_per_file_label": "This file only", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index cf1f27e4..a8ec1703 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -856,7 +856,8 @@ "bulk_drop_hint": "Suelta uno o varios archivos aquí", "bulk_drop_sub": "PDF, JPEG, PNG o TIFF · hasta 50 MB por archivo", "bulk_count_pill": "Se crearán {count}", - "bulk_save_cta": "{count, plural, one {Guardar →} other {Guardar {count} →}}", + "bulk_save_cta_one": "Guardar →", + "bulk_save_cta": "Guardar {count} →", "bulk_discard_all": "Descartar todo", "bulk_add_more": "Añadir más", "bulk_scope_per_file_label": "Solo este archivo", diff --git a/frontend/src/lib/components/document/UploadSaveBar.svelte b/frontend/src/lib/components/document/UploadSaveBar.svelte new file mode 100644 index 00000000..c7e889ef --- /dev/null +++ b/frontend/src/lib/components/document/UploadSaveBar.svelte @@ -0,0 +1,39 @@ + + +
+ {#if chunkProgress} + + {/if} +
+ + +
+
diff --git a/frontend/src/lib/components/document/UploadSaveBar.svelte.spec.ts b/frontend/src/lib/components/document/UploadSaveBar.svelte.spec.ts new file mode 100644 index 00000000..4516a831 --- /dev/null +++ b/frontend/src/lib/components/document/UploadSaveBar.svelte.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import UploadSaveBar from './UploadSaveBar.svelte'; + +afterEach(cleanup); + +describe('UploadSaveBar', () => { + it('shows plural label for multiple files', async () => { + render(UploadSaveBar, { fileCount: 5, onSave: vi.fn(), onDiscard: vi.fn() }); + // "5 speichern →" or similar plural form + await expect.element(page.getByText(/5/)).toBeInTheDocument(); + }); + + it('shows singular label for one file', async () => { + render(UploadSaveBar, { fileCount: 1, onSave: vi.fn(), onDiscard: vi.fn() }); + // "Speichern →" singular form + await expect.element(page.getByText(/Speichern/i)).toBeInTheDocument(); + }); + + it('progress bar is visible when chunkProgress is provided', async () => { + const { container } = render(UploadSaveBar, { + fileCount: 3, + chunkProgress: { done: 1, total: 3 }, + onSave: vi.fn(), + onDiscard: vi.fn() + }); + const progress = container.querySelector('progress'); + expect(progress).not.toBeNull(); + expect(progress?.getAttribute('value')).toBe('1'); + expect(progress?.getAttribute('max')).toBe('3'); + }); + + it('progress bar is not rendered when no chunkProgress', async () => { + const { container } = render(UploadSaveBar, { + fileCount: 2, + onSave: vi.fn(), + onDiscard: vi.fn() + }); + const progress = container.querySelector('progress'); + expect(progress).toBeNull(); + }); + + it('discard link is rendered', async () => { + render(UploadSaveBar, { fileCount: 2, onSave: vi.fn(), onDiscard: vi.fn() }); + await expect.element(page.getByText(/verwerfen/i)).toBeInTheDocument(); + }); +}); -- 2.49.1 From 3a6a70a1f77372ca49d569d45e25383cb16a2e64 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 17:57:33 +0200 Subject: [PATCH 08/46] feat(bulk-upload): add BulkDocumentEditLayout component with save handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State-owner for the bulk upload flow: - N=0: full-panel BulkDropZone - N=1: title + shared metadata (no switcher/scope cards) - N≥2: FileSwitcherStrip + per-file ScopeCard + shared ScopeCard Save handler chunks files at 10/request, POSTs to /api/documents/quick-upload with typed metadata JSON part, tracks progress, redirects to /documents. Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkDocumentEditLayout.svelte | 190 ++++++++++++++++++ .../BulkDocumentEditLayout.svelte.spec.ts | 99 +++++++++ .../lib/components/document/ScopeCard.svelte | 1 + .../components/document/UploadSaveBar.svelte | 1 + 4 files changed, 291 insertions(+) create mode 100644 frontend/src/lib/components/document/BulkDocumentEditLayout.svelte create mode 100644 frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte new file mode 100644 index 00000000..8a280226 --- /dev/null +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -0,0 +1,190 @@ + + +{#if files.size === 0} + +
+ +
+{:else} +
+ {#if isMulti} + (activeId = id)} + onRemove={removeFile} + /> + {/if} + +
+ + + + +
+ {#if isMulti} + + + {#if activeFile} + + {/if} + + + + + + + + {:else} + + {#if activeFile} +
+ +
+ {/if} +
+ + +
+ {/if} +
+
+ + { + files.clear(); + activeId = null; + chunkProgress = undefined; + }} + /> +
+{/if} diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts new file mode 100644 index 00000000..e063094d --- /dev/null +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function makeFile(name: string): File { + return new File(['content'], name, { type: 'application/pdf' }); +} + +async function addFilesViaInput(container: HTMLElement, files: File[]): Promise { + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + if (!input) throw new Error('No file input found — is BulkDropZone visible?'); + await userEvent.upload(input, files); +} + +describe('BulkDocumentEditLayout', () => { + it('N=0: shows BulkDropZone', async () => { + render(BulkDocumentEditLayout, {}); + await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument(); + }); + + it('N=1: file-switcher-strip and per-file scope card are absent', async () => { + const { container } = render(BulkDocumentEditLayout, {}); + await addFilesViaInput(container, [makeFile('doc.pdf')]); + expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull(); + expect(container.querySelector('[data-variant="per-file"]')).toBeNull(); + }); + + it('N=5: file-switcher-strip and per-file scope card are both present', async () => { + const { container } = render(BulkDocumentEditLayout, {}); + await addFilesViaInput(container, [ + makeFile('a.pdf'), + makeFile('b.pdf'), + makeFile('c.pdf'), + makeFile('d.pdf'), + makeFile('e.pdf') + ]); + expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(); + expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull(); + }); + + it('removing middle file preserves order of remaining files', async () => { + const { container } = render(BulkDocumentEditLayout, {}); + await addFilesViaInput(container, [ + makeFile('file0.pdf'), + makeFile('file1.pdf'), + makeFile('file2.pdf') + ]); + + // Remove the chip for file1 via its "Entfernen" remove button (second × button) + const removeButtons = container.querySelectorAll( + '[data-testid="file-switcher-strip"] button[aria-label="Entfernen"]' + ); + expect(removeButtons.length).toBe(3); + removeButtons[1].click(); // remove file1 + + // Wait for Svelte to flush the DOM update + await vi.waitFor( + () => { + const chips = container.querySelectorAll( + '[data-testid="file-switcher-strip"] [data-chip-id]' + ); + expect(chips.length).toBe(2); + expect(chips[0].textContent?.trim()).toContain('file0'); + expect(chips[1].textContent?.trim()).toContain('file2'); + }, + { timeout: 1000 } + ); + }); + + it('save calls fetch twice for 12 files (2 chunks of 10)', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ created: [], updated: [], errors: [] }) + }); + vi.stubGlobal('fetch', mockFetch); + + // Also stub goto to prevent navigation errors in test + vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + + const { container } = render(BulkDocumentEditLayout, {}); + const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`)); + await addFilesViaInput(container, files); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + expect(saveBtn).not.toBeNull(); + saveBtn.click(); + + // Wait for async save to complete + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 }); + }); +}); diff --git a/frontend/src/lib/components/document/ScopeCard.svelte b/frontend/src/lib/components/document/ScopeCard.svelte index d0e7c5c7..ebdbe82a 100644 --- a/frontend/src/lib/components/document/ScopeCard.svelte +++ b/frontend/src/lib/components/document/ScopeCard.svelte @@ -12,6 +12,7 @@ let {
-- 2.49.1 From a1f9253712ad1d1dbd0acce317fba9338fe80d54 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 17:59:41 +0200 Subject: [PATCH 09/46] feat(bulk-upload): wire /documents/new to BulkDocumentEditLayout Replaces the single-file form-action flow with BulkDocumentEditLayout, enabling multi-file drag-and-drop upload with local preview, per-file title editing, and shared metadata. Server load function unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/documents/new/+page.svelte | 133 ++---------------- 1 file changed, 8 insertions(+), 125 deletions(-) diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte index 36da0ec3..753233b1 100644 --- a/frontend/src/routes/documents/new/+page.svelte +++ b/frontend/src/routes/documents/new/+page.svelte @@ -1,49 +1,11 @@ -
- + -- 2.49.1 From 64dbce2a001521cb4fcd55ef2382010e8fe50cdb Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 18:24:16 +0200 Subject: [PATCH 10/46] =?UTF-8?q?chore(api):=20regenerate=20types=20?= =?UTF-8?q?=E2=80=94=20adds=20DocumentBatchMetadataDTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New type from the bulk-upload metadata part added in #317. Generated from backend running with --spring.profiles.active=dev. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 153 +++++++++++++++++++++++++++--- 1 file changed, 142 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index aafb9c78..2db578c9 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -548,6 +548,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/generate-thumbnails": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["generateThumbnails"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/backfill-versions": { parameters: { query?: never; @@ -1028,6 +1044,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{id}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getDocumentThumbnail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/transcription-blocks/{blockId}/history": { parameters: { query?: never; @@ -1204,6 +1236,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/thumbnail-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["thumbnailStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/import-status": { parameters: { query?: never; @@ -1390,7 +1438,6 @@ export interface components { thumbnailAspect?: "PORTRAIT" | "LANDSCAPE"; /** Format: int32 */ pageCount?: number; - thumbnailUrl?: string; originalFilename: string; /** @enum {string} */ status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; @@ -1413,6 +1460,7 @@ export interface components { sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; + thumbnailUrl?: string; }; UpdateTranscriptionBlockDTO: { text?: string; @@ -1639,6 +1687,17 @@ export interface components { /** Format: date-time */ createdAt: string; }; + DocumentBatchMetadataDTO: { + titles?: string[]; + /** Format: uuid */ + senderId?: string; + receiverIds?: string[]; + /** Format: date */ + documentDate?: string; + location?: string; + tags?: string; + metadataComplete?: boolean; + }; QuickUploadResult: { created?: components["schemas"]["Document"][]; updated?: components["schemas"]["Document"][]; @@ -1673,6 +1732,21 @@ export interface components { /** Format: date-time */ startedAt?: string; }; + BackfillStatus: { + /** @enum {string} */ + state?: "IDLE" | "RUNNING" | "DONE" | "FAILED"; + message?: string; + /** Format: int32 */ + total?: number; + /** Format: int32 */ + processed?: number; + /** Format: int32 */ + skipped?: number; + /** Format: int32 */ + failed?: number; + /** Format: date-time */ + startedAt?: string; + }; BackfillResult: { /** Format: int32 */ count: number; @@ -1837,10 +1911,10 @@ export interface components { timeout?: number; }; PageNotificationDTO: { - /** Format: int32 */ - totalPages?: number; /** Format: int64 */ totalElements?: number; + /** Format: int32 */ + totalPages?: number; pageable?: components["schemas"]["PageableObject"]; first?: boolean; last?: boolean; @@ -3152,6 +3226,7 @@ export interface operations { content: { "multipart/form-data": { files?: string[]; + metadata?: components["schemas"]["DocumentBatchMetadataDTO"]; }; }; }; @@ -3255,6 +3330,26 @@ export interface operations { }; }; }; + generateThumbnails: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BackfillStatus"]; + }; + }; + }; + }; backfillVersions: { parameters: { query?: never; @@ -3975,6 +4070,28 @@ export interface operations { }; }; }; + getDocumentThumbnail: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; getBlockHistory: { parameters: { query?: never; @@ -4038,15 +4155,9 @@ export interface operations { dir?: string; /** @description Tag operator: AND (default) or OR */ tagOp?: string; - /** - * @description Page number (0-indexed) - * @default 0 - */ + /** @description Page number (0-indexed) */ page?: number; - /** - * @description Page size (max 100) - * @default 50 - */ + /** @description Page size (max 100) */ size?: number; }; header?: never; @@ -4245,6 +4356,26 @@ export interface operations { }; }; }; + thumbnailStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BackfillStatus"]; + }; + }; + }; + }; importStatus: { parameters: { query?: never; -- 2.49.1 From 0e6efc91702d966ed5bc4b6b87015454f28309ba Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 19:17:36 +0200 Subject: [PATCH 11/46] fix(bulk-upload): spec-compliant split-panel layout with local PDF preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites BulkDocumentEditLayout to match the spec exactly: - Fixed viewport layout (same as DocumentEditLayout) filling viewport below nav - Split panel visible in all states (N=0/1/≥2) — was fullscreen dark drop zone - N=0: centered drop-zone-box in left panel; shared form visible but greyed out - N≥1: real PDF preview via URL.createObjectURL (no server upload required) - N≥2: FileSwitcherStrip at bottom of left panel; count pill + discard in topbar - FileEntry gains previewUrl; blob URLs created on add, revoked on remove/destroy - save() checks response.ok and marks failed files with status: 'error' - BulkDropZone redesigned: spec-accurate box with circular mint icon, serif title - FileSwitcherStrip: number badges, arrows, keyboard nav via data-chip-id selector - ScopeCard, UploadSaveBar: hardcoded German replaced with Paraglide i18n keys - +page.svelte simplified to bare component render (layout is self-contained) Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkDocumentEditLayout.svelte | 209 +++++++++++++----- .../components/document/BulkDropZone.svelte | 69 +++--- .../document/FileSwitcherStrip.svelte | 123 +++++++---- .../document/FileSwitcherStrip.svelte.spec.ts | 14 +- .../lib/components/document/ScopeCard.svelte | 18 +- .../components/document/UploadSaveBar.svelte | 15 +- .../src/routes/documents/new/+page.svelte | 36 +-- .../routes/documents/new/page.svelte.spec.ts | 14 +- 8 files changed, 311 insertions(+), 187 deletions(-) diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 8a280226..2c9b8511 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -1,7 +1,8 @@ -{#if files.size === 0} - -
- -
-{:else} -
+
+ +
+ + + {m.btn_back_to_overview()} + + + + {isMulti ? 'Neue Dokumente' : 'Neues Dokument'} + {#if isMulti} - (activeId = id)} - onRemove={removeFile} - /> + + + {m.bulk_count_pill({ count: files.size })} + + + {/if} +
-
- - - - -
+ +
+ +
+ {#if files.size === 0} + + + {:else} + +
+ {#if activeFile} + + {/if} +
{#if isMulti} - - + + (activeId = id)} + onRemove={removeFile} + /> + {/if} + {/if} +
+ + +
+ +
+ {#if isMulti} + + {#if activeFile} {/if} - - + {:else} - - {#if activeFile} -
-
- { - files.clear(); - activeId = null; - chunkProgress = undefined; - }} - /> + + +
-{/if} +
diff --git a/frontend/src/lib/components/document/BulkDropZone.svelte b/frontend/src/lib/components/document/BulkDropZone.svelte index 065159e3..a279ffc5 100644 --- a/frontend/src/lib/components/document/BulkDropZone.svelte +++ b/frontend/src/lib/components/document/BulkDropZone.svelte @@ -1,4 +1,6 @@ -
+
{ + e.preventDefault(); + isDragging = true; + }} + ondragleave={() => (isDragging = false)} + ondrop={(e) => { + e.preventDefault(); + isDragging = false; + if (e.dataTransfer && e.dataTransfer.files.length > 0) { + onFilesAdded(Array.from(e.dataTransfer.files)); + } + }} +>
{ - e.preventDefault(); - isDragging = true; - }} - ondragleave={() => (isDragging = false)} - ondrop={(e) => { - e.preventDefault(); - isDragging = false; - if (e.dataTransfer && e.dataTransfer.files.length > 0) { - onFilesAdded(Array.from(e.dataTransfer.files)); - } - }} + class={[ + 'flex w-full max-w-sm flex-col items-center gap-3 rounded-md border-2 border-dashed px-6 py-9 text-center transition-colors', + isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]' + ].join(' ')} > -
+ +
-

PDF-Dateien hier ablegen

-

oder

+ + +

{m.bulk_drop_hint()}

+ + +

+ Für jede Datei wird ein eigenes Dokument erstellt.
+ Der Titel wird aus dem Dateinamen vorausgefüllt — + alle anderen Felder gelten für alle gemeinsam. +

+ + + + +

{m.bulk_drop_sub()}

diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte b/frontend/src/lib/components/document/FileSwitcherStrip.svelte index e7badb4d..eef08b87 100644 --- a/frontend/src/lib/components/document/FileSwitcherStrip.svelte +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte @@ -1,9 +1,12 @@ -
    - {#each files as entry (entry.id)} -
  • - - -
  • - {/each} -
+ + +
+
    + {#each files as entry, i (entry.id)} +
  • + + +
  • + {/each} +
+
+ + +
diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts b/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts index 86968e24..56fe281a 100644 --- a/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte.spec.ts @@ -10,6 +10,7 @@ export interface FileEntry { file: File; title: string; status: 'idle' | 'error'; + previewUrl: string; } function makeFiles(n: number): FileEntry[] { @@ -17,7 +18,8 @@ function makeFiles(n: number): FileEntry[] { id: `id-${i}`, file: new File([''], `file${i}.pdf`), title: `File ${i}`, - status: 'idle' as const + status: 'idle' as const, + previewUrl: '' })); } @@ -65,7 +67,13 @@ describe('FileSwitcherStrip', () => { it('error chip has aria-label containing warning indicator', async () => { const files: FileEntry[] = [ - { id: 'e1', file: new File([''], 'bad.pdf'), title: 'Bad file', status: 'error' } + { + id: 'e1', + file: new File([''], 'bad.pdf'), + title: 'Bad file', + status: 'error', + previewUrl: '' + } ]; const { container } = render(FileSwitcherStrip, { files, @@ -85,7 +93,7 @@ describe('FileSwitcherStrip', () => { onSelect: vi.fn(), onRemove: vi.fn() }); - const firstBtn = container.querySelectorAll('[role="button"]')[0] as HTMLElement; + const firstBtn = container.querySelectorAll('[data-chip-id]')[0] as HTMLElement; firstBtn.focus(); await userEvent.keyboard('{ArrowRight}'); const focused = document.activeElement; diff --git a/frontend/src/lib/components/document/ScopeCard.svelte b/frontend/src/lib/components/document/ScopeCard.svelte index ebdbe82a..7eb6002d 100644 --- a/frontend/src/lib/components/document/ScopeCard.svelte +++ b/frontend/src/lib/components/document/ScopeCard.svelte @@ -1,4 +1,6 @@ -
+
{#if chunkProgress} {/if} -
+
diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte index 753233b1..c4cb69f8 100644 --- a/frontend/src/routes/documents/new/+page.svelte +++ b/frontend/src/routes/documents/new/+page.svelte @@ -1,37 +1,11 @@ -
-
- - - - - {m.btn_back_to_overview()} - -

{m.doc_new_heading()}

-
- - -
+ diff --git a/frontend/src/routes/documents/new/page.svelte.spec.ts b/frontend/src/routes/documents/new/page.svelte.spec.ts index dcdfbdef..b22028bf 100644 --- a/frontend/src/routes/documents/new/page.svelte.spec.ts +++ b/frontend/src/routes/documents/new/page.svelte.spec.ts @@ -21,15 +21,14 @@ const baseData = { describe('New document page – sender prefill', () => { it('shows an empty sender input when no senderId is in the URL', async () => { - render(Page, { data: baseData, form: null }); + render(Page, { data: baseData }); const input = document.querySelector('#senderId-search'); expect(input?.value).toBe(''); }); it('shows the sender name in the typeahead input when initialSenderName is set', async () => { render(Page, { - data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }, - form: null + data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' } }); const input = document.querySelector('#senderId-search'); expect(input?.value).toBe('Hans Müller'); @@ -37,8 +36,7 @@ describe('New document page – sender prefill', () => { it('sets the hidden senderId input to the prefilled ID', async () => { render(Page, { - data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }, - form: null + data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' } }); const hidden = document.querySelector( 'input[type="hidden"][name="senderId"]' @@ -51,7 +49,7 @@ describe('New document page – sender prefill', () => { describe('New document page – receiver prefill', () => { it('shows no receiver chips when initialReceivers is empty', async () => { - render(Page, { data: baseData, form: null }); + render(Page, { data: baseData }); await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument(); }); @@ -62,7 +60,7 @@ describe('New document page – receiver prefill', () => { { id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' } ] }; - render(Page, { data, form: null }); + render(Page, { data }); await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); }); @@ -73,7 +71,7 @@ describe('New document page – receiver prefill', () => { { id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' } ] }; - render(Page, { data, form: null }); + render(Page, { data }); const hidden = document.querySelector('input[name="receiverIds"]'); expect(hidden?.value).toBe('p2'); }); -- 2.49.1 From aa9c47ecc8a97306e605795bcac4ededd76aab62 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 19:26:22 +0200 Subject: [PATCH 12/46] fix(bulk-upload): form layout polish and drop zone sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop zone box doubled: max-w-xl, larger icon (80px), bigger padding and text - Title field wrapped in its own card (matches WhoWhenSection/DescriptionSection) - Removed double-wrapping outer card around WhoWhenSection + DescriptionSection - Added space-y-4 between form sections for consistent breathing room - ScopeCard per-file label: text-accent → text-primary for legible contrast in light theme Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkDocumentEditLayout.svelte | 26 +++++++++---------- .../components/document/BulkDropZone.svelte | 16 ++++++------ .../lib/components/document/ScopeCard.svelte | 2 +- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 2c9b8511..1dfba569 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -197,7 +197,7 @@ async function save() {
@@ -231,9 +231,9 @@ async function save() { {:else} -
+
-
- - -
+ + {/if}
diff --git a/frontend/src/lib/components/document/BulkDropZone.svelte b/frontend/src/lib/components/document/BulkDropZone.svelte index a279ffc5..b763370e 100644 --- a/frontend/src/lib/components/document/BulkDropZone.svelte +++ b/frontend/src/lib/components/document/BulkDropZone.svelte @@ -30,15 +30,15 @@ let isDragging = $state(false); >
-
+

{m.bulk_drop_hint()}

+

{m.bulk_drop_hint()}

-

+

Für jede Datei wird ein eigenes Dokument erstellt.
Der Titel wird aus dem Dateinamen vorausgefüllt — alle anderen Felder gelten für alle gemeinsam. @@ -62,7 +62,7 @@ let isDragging = $state(false);

{m.bulk_drop_sub()}

+

{m.bulk_drop_sub()}

diff --git a/frontend/src/lib/components/document/ScopeCard.svelte b/frontend/src/lib/components/document/ScopeCard.svelte index 7eb6002d..6fb79f96 100644 --- a/frontend/src/lib/components/document/ScopeCard.svelte +++ b/frontend/src/lib/components/document/ScopeCard.svelte @@ -32,7 +32,7 @@ let {
{:else} -

+

{m.bulk_scope_per_file_label()}

{/if} -- 2.49.1 From 8b05451f42720c18a6259ce694ca8b2d7559822b Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 19:29:21 +0200 Subject: [PATCH 13/46] fix(bulk-upload): PDF-only file acceptance Drop non-PDF accept types from file input and update format hint strings in all three languages. JPEG/PNG/TIFF were never officially supported. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 +- frontend/messages/en.json | 2 +- frontend/messages/es.json | 2 +- frontend/src/lib/components/document/BulkDropZone.svelte | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 479238c1..1a0a0fd0 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -854,7 +854,7 @@ "richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.", "error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.", "bulk_drop_hint": "Eine oder mehrere Dateien ablegen", - "bulk_drop_sub": "PDF, JPEG, PNG oder TIFF · bis zu 50 MB pro Datei", + "bulk_drop_sub": "PDF · bis zu 50 MB pro Datei", "bulk_count_pill": "{count} werden erstellt", "bulk_save_cta_one": "Speichern →", "bulk_save_cta": "{count} speichern →", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 78e107e4..95c52c82 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -854,7 +854,7 @@ "richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.", "error_batch_too_large": "Too many files at once — please upload in smaller batches.", "bulk_drop_hint": "Drop one or more files here", - "bulk_drop_sub": "PDF, JPEG, PNG or TIFF · up to 50 MB per file", + "bulk_drop_sub": "PDF · up to 50 MB per file", "bulk_count_pill": "{count} will be created", "bulk_save_cta_one": "Save →", "bulk_save_cta": "Save {count} →", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index a8ec1703..61050e5d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -854,7 +854,7 @@ "richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.", "error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.", "bulk_drop_hint": "Suelta uno o varios archivos aquí", - "bulk_drop_sub": "PDF, JPEG, PNG o TIFF · hasta 50 MB por archivo", + "bulk_drop_sub": "PDF · hasta 50 MB por archivo", "bulk_count_pill": "Se crearán {count}", "bulk_save_cta_one": "Guardar →", "bulk_save_cta": "Guardar {count} →", diff --git a/frontend/src/lib/components/document/BulkDropZone.svelte b/frontend/src/lib/components/document/BulkDropZone.svelte index b763370e..376f4b01 100644 --- a/frontend/src/lib/components/document/BulkDropZone.svelte +++ b/frontend/src/lib/components/document/BulkDropZone.svelte @@ -68,7 +68,7 @@ let isDragging = $state(false); { const files = Array.from(e.currentTarget.files ?? []); -- 2.49.1 From db6a3225dbf0ed5542312494b056394965786fcb Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 19:32:23 +0200 Subject: [PATCH 14/46] fix(bulk-upload): no layout shift, no autofocus on date field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace JS navHeight measurement with CSS var(--header-height) so the fixed panel renders in its final position on first paint — no onMount shift. Add autofocus prop to WhoWhenSection (default true, preserves document-edit behaviour) and pass autofocus={false} from BulkDocumentEditLayout so the date field does not steal focus before the user has even dropped any files. Co-Authored-By: Claude Sonnet 4.6 --- .../components/document/BulkDocumentEditLayout.svelte | 11 +++-------- .../src/lib/components/document/WhoWhenSection.svelte | 8 +++++--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 1dfba569..0cf98694 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -1,7 +1,7 @@ -
+
-- 2.49.1 From 75dd8cb08d26cad3fd51490ffeba0be27f1c50bb Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 19:36:20 +0200 Subject: [PATCH 15/46] fix(PersonMultiSelect): align height and focus ring with other form inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit min-h-[42px] → min-h-[38px] to match p-2 text-sm input height. Add shadow-sm (was missing vs date/sender inputs). focus-within:ring-1 ring-ink → focus-within:ring-2 ring-focus-ring to match the focus style used consistently across all other form inputs. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonMultiSelect.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte index 7847787f..6ff453c9 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte +++ b/frontend/src/lib/components/PersonMultiSelect.svelte @@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
(showDropdown = false)}>
{#each selectedPersons as person (person.id)} Date: Fri, 24 Apr 2026 19:38:54 +0200 Subject: [PATCH 16/46] fix(PersonTypeahead): match height and border-radius of other form inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default mode was text-base (16px) and rounded-md — date field uses text-sm (14px) and rounded. Aligning these makes Sender/Date/Receiver rows consistent. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonTypeahead.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 45ef34c7..cc667d2d 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -134,7 +134,7 @@ function selectPerson(person: Person) { ? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring' : compact ? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring' - : 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'} + : 'mt-1 block w-full rounded border border-line bg-surface p-2 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'} /> {#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)} -- 2.49.1 From 1ea95f8fe0219002d65229ed7a0915cc9622d171 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 19:45:03 +0200 Subject: [PATCH 17/46] fix(forms): raise date and sender field height to match receiver (44px) PersonMultiSelect naturally renders at 44px due to nested padding (outer p-2 + inner p-1). Apply py-3 px-2 to the date input and PersonTypeahead default mode so all three fields align visually. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonTypeahead.svelte | 2 +- frontend/src/lib/components/document/WhoWhenSection.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index cc667d2d..815623da 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -134,7 +134,7 @@ function selectPerson(person: Person) { ? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring' : compact ? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring' - : 'mt-1 block w-full rounded border border-line bg-surface p-2 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'} + : 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'} /> {#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)} diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index ff8a044b..d315be42 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -72,7 +72,7 @@ $effect(() => { placeholder={m.form_placeholder_date()} maxlength="10" autofocus={autofocus && !initialDateIso} - class="block w-full rounded border border-line p-2 text-sm shadow-sm + class="block w-full rounded border border-line px-2 py-3 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} /> -- 2.49.1 From 9d687ba9f91168bbd8f41c7e67a683d46bfe2e81 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 19:48:47 +0200 Subject: [PATCH 18/46] fix(forms): apply py-3 to location input for consistent 44px height Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/document/WhoWhenSection.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index d315be42..c8d03e24 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -112,7 +112,7 @@ $effect(() => { name="location" value={initialLocation} placeholder={m.form_placeholder_location()} - 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" + class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" />
-- 2.49.1 From dd6331c09845bc9214c940f2c0361d38421603a8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 19:51:15 +0200 Subject: [PATCH 19/46] fix(forms): remove autofocus from WhoWhenSection entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autofocus prop was added conditionally but still triggered on the bulk-upload page. Removing it completely — callers that need focus management can handle it independently. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/document/BulkDocumentEditLayout.svelte | 1 - frontend/src/lib/components/document/WhoWhenSection.svelte | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 0cf98694..c1ece8b1 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -220,7 +220,6 @@ async function save() { bind:selectedReceivers={selectedReceivers} bind:dateIso={dateIso} initialSenderName={initialSenderName} - autofocus={false} /> diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index c8d03e24..679ecc84 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -16,8 +16,7 @@ let { initialLocation = '', initialSenderName = '', suggestedDateIso = '', - suggestedSenderName = '', - autofocus = true + suggestedSenderName = '' }: { senderId?: string; selectedReceivers?: Person[]; @@ -27,7 +26,6 @@ let { initialSenderName?: string; suggestedDateIso?: string; suggestedSenderName?: string; - autofocus?: boolean; } = $props(); let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso))); @@ -71,7 +69,6 @@ $effect(() => { oninput={handleDateInput} placeholder={m.form_placeholder_date()} maxlength="10" - autofocus={autofocus && !initialDateIso} class="block w-full rounded border border-line px-2 py-3 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} @@ -91,7 +88,6 @@ $effect(() => { bind:value={senderId} initialName={initialSenderName} suggestedName={suggestedSenderName} - autofocus={autofocus && !!initialDateIso} />
-- 2.49.1 From b2264de9493f38cbf768ce7ad434970db4de8961 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 20:25:17 +0200 Subject: [PATCH 20/46] refactor(documents): move batch validation from controller into DocumentService Validation guards (BATCH_TOO_LARGE, titles > files) are domain rules and belong in the service where they can be unit-tested without the HTTP layer. Controller now delegates to documentService.validateBatch(). Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 7 +----- .../service/DocumentService.java | 9 +++++++ .../controller/DocumentControllerTest.java | 8 ++++++ .../service/DocumentServiceTest.java | 25 +++++++++++++++++++ 4 files changed, 43 insertions(+), 6 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 cd03a22f..ea943163 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -204,12 +204,7 @@ public class DocumentController { return new QuickUploadResult(created, updated, errors); } - if (files.size() > 50) { - throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"); - } - if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > files.size()) { - throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"); - } + documentService.validateBatch(files.size(), metadata); UUID actorId = requireUserId(authentication); long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum(); 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 36575f28..01ea544d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -133,6 +133,15 @@ public class DocumentService { return new StoreResult(saved, isNew); } + public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) { + if (fileCount > 50) { + throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"); + } + if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"); + } + } + @Transactional public StoreResult storeDocumentWithBatchMetadata( MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException { 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 1981b1b2..828e8402 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -869,6 +869,10 @@ class DocumentControllerTest { @WithMockUser(authorities = "WRITE_ALL") void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception { when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + org.mockito.Mockito.doThrow( + org.raddatz.familienarchiv.exception.DomainException.badRequest( + org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count")) + .when(documentService).validateBatch(eq(2), any()); org.springframework.mock.web.MockMultipartFile f1 = new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1}); @@ -886,6 +890,10 @@ class DocumentControllerTest { @WithMockUser(authorities = "WRITE_ALL") void quickUpload_returns400_whenBatchExceedsCap() throws Exception { when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + org.mockito.Mockito.doThrow( + org.raddatz.familienarchiv.exception.DomainException.badRequest( + org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request")) + .when(documentService).validateBatch(eq(51), any()); var builder = multipart("/api/documents/quick-upload"); for (int i = 0; i < 51; i++) { 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 0e46196f..052658a8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1813,4 +1813,29 @@ class DocumentServiceTest { verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull()); } + + // ─── validateBatch ─────────────────────────────────────────────────────── + + @Test + void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() { + assertThatThrownBy(() -> documentService.validateBatch(51, null)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("50"); + } + + @Test + void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() { + documentService.validateBatch(50, null); + } + + @Test + void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() { + org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata = + new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO(); + metadata.setTitles(java.util.List.of("A", "B", "C")); + + assertThatThrownBy(() -> documentService.validateBatch(2, metadata)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("titles"); + } } -- 2.49.1 From 1eb833f333cf10eb94c4f78a503df2a32a76f830 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 20:28:12 +0200 Subject: [PATCH 21/46] refactor(documents): change DocumentBatchMetadataDTO.tags from String to List tagNames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces comma-delimited String with a proper JSON array field — callers no longer need to pre-serialise. Service drops the split/trim/filter step and passes tagNames directly to updateDocumentTags(). Co-Authored-By: Claude Sonnet 4.6 --- .../dto/DocumentBatchMetadataDTO.java | 2 +- .../service/DocumentService.java | 6 ++--- .../controller/DocumentControllerTest.java | 24 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java index 93e17fbf..399105d9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchMetadataDTO.java @@ -13,6 +13,6 @@ public class DocumentBatchMetadataDTO { private List receiverIds; private LocalDate documentDate; private String location; - private String tags; + private List tagNames; private Boolean metadataComplete; } 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 01ea544d..dac82517 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -166,11 +166,9 @@ public class DocumentService { if (metadata.getMetadataComplete() != null) { doc.setMetadataComplete(metadata.getMetadataComplete()); } - if (metadata.getTags() != null && !metadata.getTags().isBlank()) { - List tagNames = Arrays.stream(metadata.getTags().split(",")) - .map(String::trim).filter(s -> !s.isEmpty()).toList(); + if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) { UUID docId = doc.getId(); - updateDocumentTags(docId, tagNames); + updateDocumentTags(docId, metadata.getTagNames()); doc = documentRepository.findById(docId) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId)); } 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 828e8402..b80ed173 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -886,6 +886,30 @@ class DocumentControllerTest { .andExpect(status().isBadRequest()); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception { + Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build(); + when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class); + when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any())) + .thenReturn(new DocumentService.StoreResult(doc, true)); + + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1}); + org.springframework.mock.web.MockMultipartFile metadata = + new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", + "{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes()); + + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata)) + .andExpect(status().isOk()); + + org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames()) + .containsExactly("Briefwechsel", "Krieg"); + } + @Test @WithMockUser(authorities = "WRITE_ALL") void quickUpload_returns400_whenBatchExceedsCap() throws Exception { -- 2.49.1 From f90d4b282e9067731abbfacb05f8325c2a0a0e00 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 20:30:15 +0200 Subject: [PATCH 22/46] refactor(documents): extract applyBatchMetadata private helper in DocumentService storeDocumentWithBatchMetadata was a 30-line flat method mixing file storage with metadata hydration. The private helper makes each concern visible at a glance. Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/service/DocumentService.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 dac82517..06317642 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -146,8 +146,11 @@ public class DocumentService { public StoreResult storeDocumentWithBatchMetadata( MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException { StoreResult base = storeDocument(file, actorId); - Document doc = base.document(); + Document doc = applyBatchMetadata(base.document(), metadata, fileIndex); + return new StoreResult(documentRepository.save(doc), base.isNew()); + } + private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) { if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) { doc.setTitle(metadata.getTitles().get(fileIndex)); } @@ -172,9 +175,7 @@ public class DocumentService { doc = documentRepository.findById(docId) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId)); } - - Document saved = documentRepository.save(doc); - return new StoreResult(saved, base.isNew()); + return doc; } @Transactional -- 2.49.1 From 43122c20cbca756fc59be782ba58efa1dc2cbcad Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 20:38:39 +0200 Subject: [PATCH 23/46] fix(bulk-upload): i18n hardcoded strings in BulkDropZone and FileSwitcherStrip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bulk_drop_desc, bulk_select_files, bulk_drop_zone_label, bulk_remove_file keys to de/en/es message files - BulkDropZone: use m.bulk_drop_zone_label(), m.bulk_drop_desc(), m.bulk_select_files() — removes all hardcoded German - FileSwitcherStrip: use m.bulk_remove_file() on × button; move aria-live from