From 0ce18e1eedc3b7c3167a27273abec86d356d6340 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 13:25:57 +0100 Subject: [PATCH 1/2] feat(documents): add metadataComplete flag and enrichment queue endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a metadata_complete column (default true for existing rows) to drive the enrichment queue. New drop-zone uploads always start as false; createDocument uses an explicit DTO flag or a heuristic (any of date/sender/receivers present → true); the mass importer applies the same heuristic per row. New endpoints: GET /api/documents/incomplete-count, /incomplete, /incomplete/next. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 19 +++ .../familienarchiv/dto/DocumentUpdateDTO.java | 1 + .../familienarchiv/model/Document.java | 5 + .../repository/DocumentRepository.java | 6 + .../service/DocumentService.java | 32 ++++ .../service/MassImportService.java | 4 + ...15__add_metadata_complete_to_documents.sql | 6 + .../controller/DocumentControllerTest.java | 73 ++++++++ .../service/DocumentServiceTest.java | 160 ++++++++++++++++++ 9 files changed, 306 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V15__add_metadata_complete_to_documents.sql 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 8cafba7a..d66e935a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -4,6 +4,8 @@ import java.io.IOException; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -156,6 +158,23 @@ public class DocumentController { return new QuickUploadResult(created, updated, errors); } + @GetMapping("/incomplete-count") + public Map getIncompleteCount() { + return Map.of("count", documentService.getIncompleteCount()); + } + + @GetMapping("/incomplete") + public List getIncomplete() { + return documentService.findIncompleteDocuments(); + } + + @GetMapping("/incomplete/next") + public ResponseEntity getNextIncomplete(@RequestParam UUID excludeId) { + return documentService.findNextIncompleteDocument(excludeId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.noContent().build()); + } + @GetMapping("/search") public ResponseEntity> search( @RequestParam(required = false) String q, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentUpdateDTO.java index 3649ddea..79789f24 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentUpdateDTO.java @@ -17,4 +17,5 @@ public class DocumentUpdateDTO { private UUID senderId; private List receiverIds; private String tags; + private Boolean metadataComplete; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index 5fa21c57..f72e3f5e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -86,6 +86,11 @@ public class Document { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime updatedAt; + @Column(name = "metadata_complete", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private boolean metadataComplete = false; + @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id")) @Builder.Default diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java index 25da2dcd..a878b67a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java @@ -42,6 +42,12 @@ public interface DocumentRepository extends JpaRepository, JpaSp List findByFileHashIsNullAndFilePathIsNotNull(); + long countByMetadataCompleteFalse(); + + List findByMetadataCompleteFalse(Sort sort); + + Optional findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort); + @Query("SELECT DISTINCT d FROM Document d " + "JOIN d.receivers r " + "WHERE " + 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 cd878814..95cd0921 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -62,10 +62,12 @@ public class DocumentService { if (existingDoc.isPresent()) { document = existingDoc.get(); } else { + // New uploads from the drop zone always start as incomplete document = Document.builder() .originalFilename(originalFilename) .title(stripExtension(originalFilename)) .status(DocumentStatus.UPLOADED) + .metadataComplete(false) .build(); } @@ -89,6 +91,17 @@ public class DocumentService { ? file.getOriginalFilename() : (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument"); + // If the caller explicitly sets metadataComplete, use it. + // Otherwise apply heuristic: complete if at least one key field is present. + boolean metadataComplete; + if (dto.getMetadataComplete() != null) { + metadataComplete = dto.getMetadataComplete(); + } else { + metadataComplete = dto.getDocumentDate() != null + || dto.getSenderId() != null + || (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()); + } + Document doc = Document.builder() .originalFilename(filename) .title(dto.getTitle()) @@ -98,6 +111,7 @@ public class DocumentService { .transcription(dto.getTranscription()) .summary(dto.getSummary()) .status(DocumentStatus.PLACEHOLDER) + .metadataComplete(metadataComplete) .build(); doc = documentRepository.save(doc); @@ -176,6 +190,11 @@ public class DocumentService { doc.getReceivers().clear(); // Alle entfernen } + // 3b. metadataComplete — only update when explicitly set in the DTO + if (dto.getMetadataComplete() != null) { + doc.setMetadataComplete(dto.getMetadataComplete()); + } + // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde) if (newFile != null && !newFile.isEmpty()) { FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename()); @@ -280,6 +299,19 @@ public class DocumentService { return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort); } + public long getIncompleteCount() { + return documentRepository.countByMetadataCompleteFalse(); + } + + public List findIncompleteDocuments() { + return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt")); + } + + public Optional findNextIncompleteDocument(UUID currentId) { + return documentRepository.findFirstByMetadataCompleteFalseAndIdNot( + currentId, Sort.by(Sort.Direction.DESC, "createdAt")); + } + @Transactional public void deleteDocument(UUID id) { if (!documentRepository.existsById(id)) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java index 522bcfd7..9f35733b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java @@ -312,6 +312,9 @@ public class MassImportService { .originalFilename(originalFilename) .build()); + // Heuristic: mark as complete if at least one key field is present in the spreadsheet row + boolean metadataComplete = date != null || !senderRaw.isBlank() || !receiversRaw.isBlank(); + doc.setTitle(buildTitle(index, date, location)); doc.setFilePath(s3Key); doc.setContentType(contentType); @@ -325,6 +328,7 @@ public class MassImportService { doc.setSender(sender); doc.getReceivers().addAll(receivers); if (tag != null) doc.getTags().add(tag); + doc.setMetadataComplete(metadataComplete); documentRepository.save(doc); log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename); diff --git a/backend/src/main/resources/db/migration/V15__add_metadata_complete_to_documents.sql b/backend/src/main/resources/db/migration/V15__add_metadata_complete_to_documents.sql new file mode 100644 index 00000000..0d170d86 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__add_metadata_complete_to_documents.sql @@ -0,0 +1,6 @@ +-- Add metadata_complete flag to documents. +-- Existing rows default to true (already reviewed before this feature existed). +-- New documents created via Java will receive false from the entity default. + +ALTER TABLE documents + ADD COLUMN metadata_complete BOOLEAN NOT NULL DEFAULT TRUE; 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 f4f6c439..749abd88 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -21,6 +21,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; @@ -212,6 +213,78 @@ class DocumentControllerTest { .andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE")); } + // ─── GET /api/documents/incomplete-count ───────────────────────────────── + + @Test + void getIncompleteCount_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/incomplete-count")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getIncompleteCount_returns200_withCount() throws Exception { + when(documentService.getIncompleteCount()).thenReturn(3L); + + mockMvc.perform(get("/api/documents/incomplete-count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(3)); + } + + // ─── GET /api/documents/incomplete ─────────────────────────────────────── + + @Test + void getIncomplete_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/incomplete")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getIncomplete_returns200_withList() throws Exception { + Document doc = Document.builder() + .id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build(); + when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc)); + + mockMvc.perform(get("/api/documents/incomplete")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("Unvollständig")); + } + + // ─── GET /api/documents/incomplete/next ────────────────────────────────── + + @Test + void getNextIncomplete_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/incomplete/next") + .param("excludeId", UUID.randomUUID().toString())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getNextIncomplete_returns200_whenNextExists() throws Exception { + UUID excludeId = UUID.randomUUID(); + Document next = Document.builder() + .id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build(); + when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next)); + + mockMvc.perform(get("/api/documents/incomplete/next") + .param("excludeId", excludeId.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Nächster")); + } + + @Test + @WithMockUser + void getNextIncomplete_returns204_whenNoneRemain() throws Exception { + UUID excludeId = UUID.randomUUID(); + when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/documents/incomplete/next") + .param("excludeId", excludeId.toString())) + .andExpect(status().isNoContent()); + } + // ─── GET /api/documents/{id}/versions ──────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 984be862..195810ab 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -11,7 +12,10 @@ import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.data.domain.Sort; +import org.springframework.mock.web.MockMultipartFile; +import java.time.LocalDate; import java.util.HashSet; import java.util.List; import java.util.Optional; @@ -344,6 +348,162 @@ class DocumentServiceTest { verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any()); } + // ─── getIncompleteCount ─────────────────────────────────────────────────── + + @Test + void getIncompleteCount_delegatesToRepository() { + when(documentRepository.countByMetadataCompleteFalse()).thenReturn(5L); + assertThat(documentService.getIncompleteCount()).isEqualTo(5L); + } + + // ─── findIncompleteDocuments ────────────────────────────────────────────── + + @Test + void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() { + Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build(); + when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc)); + + assertThat(documentService.findIncompleteDocuments()).containsExactly(doc); + verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt")); + } + + // ─── findNextIncompleteDocument ─────────────────────────────────────────── + + @Test + void findNextIncompleteDocument_returnsNext_whenAnotherIncompleteExists() { + UUID currentId = UUID.randomUUID(); + Document next = Document.builder().id(UUID.randomUUID()).title("Next").build(); + when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class))) + .thenReturn(Optional.of(next)); + + assertThat(documentService.findNextIncompleteDocument(currentId)).contains(next); + } + + @Test + void findNextIncompleteDocument_returnsEmpty_whenNoMoreIncomplete() { + UUID currentId = UUID.randomUUID(); + when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class))) + .thenReturn(Optional.empty()); + + assertThat(documentService.findNextIncompleteDocument(currentId)).isEmpty(); + } + + // ─── storeDocument metadataComplete ────────────────────────────────────── + + @Test + void storeDocument_setsMetadataCompleteFalse_forNewDocument() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1}); + Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf").build(); + when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenReturn(saved); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.storeDocument(file); + + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().isMetadataComplete()).isFalse(); + } + + @Test + void storeDocument_doesNotChangeMetadataComplete_forExistingDocument() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1}); + Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf") + .status(DocumentStatus.PLACEHOLDER).metadataComplete(true).build(); + when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.of(existing)); + when(documentRepository.save(any())).thenReturn(existing); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + + documentService.storeDocument(file); + + assertThat(existing.isMetadataComplete()).isTrue(); + } + + // ─── createDocument metadataComplete ───────────────────────────────────── + + @Test + void createDocument_setsMetadataCompleteFromDto_whenExplicitlyProvided() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("Doc"); + dto.setMetadataComplete(true); + Document saved = Document.builder().id(UUID.randomUUID()).title("Doc") + .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, null); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue(); + } + + @Test + void createDocument_setsMetadataCompleteFalse_whenAllKeyFieldsMissingAndNoExplicitFlag() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("Doc"); + // no documentDate, no senderId, no receiverIds, no metadataComplete flag + Document saved = Document.builder().id(UUID.randomUUID()).title("Doc") + .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, null); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse(); + } + + @Test + void createDocument_setsMetadataCompleteTrue_whenDatePresentAndNoExplicitFlag() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("Doc"); + dto.setDocumentDate(LocalDate.of(2020, 1, 1)); + Document saved = Document.builder().id(UUID.randomUUID()).title("Doc") + .originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, null); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue(); + } + + // ─── updateDocument metadataComplete ───────────────────────────────────── + + @Test + void updateDocument_setsMetadataComplete_whenDtoHasValue() throws Exception { + UUID id = UUID.randomUUID(); + Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf") + .status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(existing)); + when(documentRepository.save(any())).thenReturn(existing); + + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setMetadataComplete(true); + documentService.updateDocument(id, dto, null); + + assertThat(existing.isMetadataComplete()).isTrue(); + } + + @Test + void updateDocument_doesNotChangeMetadataComplete_whenDtoHasNull() throws Exception { + UUID id = UUID.randomUUID(); + Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf") + .status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(existing)); + when(documentRepository.save(any())).thenReturn(existing); + + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + // metadataComplete not set → null + documentService.updateDocument(id, dto, null); + + assertThat(existing.isMetadataComplete()).isFalse(); + } + @Test void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception { UUID id1 = UUID.randomUUID(); -- 2.49.1 From aab9e9a4b0122d39e49b4bc581864307d91a7086 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 13:45:16 +0100 Subject: [PATCH 2/2] feat(enrich): add metadata enrichment queue UI Home page shows "Needs metadata" card when incomplete documents exist. /enrich list shows all incomplete documents; /enrich/[id] provides a split PDF-preview + compact form view with Skip / Save / Save & reviewed actions that auto-advance through the queue. New document page gets Save vs Save & reviewed split. Edit page gets "Mark for review" secondary button to push a document back into the queue. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 18 +- frontend/messages/en.json | 18 +- frontend/messages/es.json | 18 +- frontend/src/lib/generated/api.ts | 119 +++++++++++++ frontend/src/routes/+page.server.ts | 11 +- frontend/src/routes/+page.svelte | 30 ++++ .../documents/[id]/edit/+page.server.ts | 47 +++++ .../routes/documents/[id]/edit/SaveBar.svelte | 12 +- .../src/routes/documents/new/+page.server.ts | 49 ++++-- .../src/routes/documents/new/+page.svelte | 26 ++- frontend/src/routes/enrich/+page.server.ts | 23 +++ frontend/src/routes/enrich/+page.svelte | 106 ++++++++++++ .../src/routes/enrich/[id]/+page.server.ts | 109 ++++++++++++ frontend/src/routes/enrich/[id]/+page.svelte | 162 ++++++++++++++++++ frontend/src/routes/enrich/done/+page.svelte | 40 +++++ frontend/src/routes/page.svelte.spec.ts | 1 + 16 files changed, 762 insertions(+), 27 deletions(-) create mode 100644 frontend/src/routes/enrich/+page.server.ts create mode 100644 frontend/src/routes/enrich/+page.svelte create mode 100644 frontend/src/routes/enrich/[id]/+page.server.ts create mode 100644 frontend/src/routes/enrich/[id]/+page.svelte create mode 100644 frontend/src/routes/enrich/done/+page.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c033a147..c441cbf8 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -274,5 +274,21 @@ "upload_duplicate": "{filename} existiert bereits —", "upload_duplicate_link": "Zum Dokument", "upload_invalid_type": "{filename}: Dateiformat nicht unterstützt", - "upload_error": "Fehler beim Hochladen von {filename}" + "upload_error": "Fehler beim Hochladen von {filename}", + "enrich_list_back": "Zurück zur Übersicht", + "enrich_list_count": "Dokumente", + "btn_save_and_mark_reviewed": "Speichern & abschließen", + "btn_mark_for_review": "Zur Überprüfung markieren", + "enrich_needs_metadata_title": "Dokumente ohne Metadaten", + "enrich_needs_metadata_count": "{count} Dokument(e) warten auf Metadaten", + "enrich_needs_metadata_cta": "Jetzt vervollständigen", + "enrich_list_heading": "Dokumente ohne Metadaten", + "enrich_list_empty_heading": "Alle Dokumente vollständig", + "enrich_list_empty_body": "Es gibt keine Dokumente, die noch Metadaten benötigen.", + "enrich_list_start": "Überprüfung starten", + "enrich_progress": "{count} verbleibend", + "enrich_skip": "Überspringen", + "enrich_done_heading": "Alles erledigt!", + "enrich_done_body": "Alle Dokumente wurden bearbeitet.", + "enrich_back_to_list": "Zurück zur Liste" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f9d53545..abd1c189 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -274,5 +274,21 @@ "upload_duplicate": "{filename} already exists —", "upload_duplicate_link": "View document", "upload_invalid_type": "{filename}: unsupported file format", - "upload_error": "Error uploading {filename}" + "upload_error": "Error uploading {filename}", + "enrich_list_back": "Back to overview", + "enrich_list_count": "documents", + "btn_save_and_mark_reviewed": "Save & mark as reviewed", + "btn_mark_for_review": "Mark for review", + "enrich_needs_metadata_title": "Documents without metadata", + "enrich_needs_metadata_count": "{count} document(s) waiting for metadata", + "enrich_needs_metadata_cta": "Complete now", + "enrich_list_heading": "Documents without metadata", + "enrich_list_empty_heading": "All documents complete", + "enrich_list_empty_body": "There are no documents that still need metadata.", + "enrich_list_start": "Start reviewing", + "enrich_progress": "{count} remaining", + "enrich_skip": "Skip", + "enrich_done_heading": "All done!", + "enrich_done_body": "All documents have been processed.", + "enrich_back_to_list": "Back to list" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 958fb02e..0839bf4e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -274,5 +274,21 @@ "upload_duplicate": "{filename} ya existe —", "upload_duplicate_link": "Ver documento", "upload_invalid_type": "{filename}: formato de archivo no admitido", - "upload_error": "Error al subir {filename}" + "upload_error": "Error al subir {filename}", + "enrich_list_back": "Volver a la vista general", + "enrich_list_count": "documentos", + "btn_save_and_mark_reviewed": "Guardar y marcar como revisado", + "btn_mark_for_review": "Marcar para revisión", + "enrich_needs_metadata_title": "Documentos sin metadatos", + "enrich_needs_metadata_count": "{count} documento(s) esperando metadatos", + "enrich_needs_metadata_cta": "Completar ahora", + "enrich_list_heading": "Documentos sin metadatos", + "enrich_list_empty_heading": "Todos los documentos completos", + "enrich_list_empty_body": "No hay documentos que necesiten metadatos.", + "enrich_list_start": "Comenzar revisión", + "enrich_progress": "{count} restante(s)", + "enrich_skip": "Omitir", + "enrich_done_heading": "¡Todo listo!", + "enrich_done_body": "Todos los documentos han sido procesados.", + "enrich_back_to_list": "Volver a la lista" } diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9659f8a1..c6fb5ac0 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -468,6 +468,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/incomplete-count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getIncompleteCount"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/incomplete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getIncomplete"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/incomplete/next": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getNextIncomplete"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/search": { parameters: { query?: never; @@ -1819,6 +1867,77 @@ export interface operations { }; }; }; + getIncompleteCount: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + count: number; + }; + }; + }; + }; + }; + getIncomplete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Document"][]; + }; + }; + }; + }; + getNextIncomplete: { + parameters: { + query: { + excludeId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Document"]; + }; + }; + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; importStatus: { parameters: { query?: never; diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index b9ed284a..248fc783 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -12,7 +12,7 @@ export async function load({ url, fetch }) { const api = createApiClient(fetch); try { - const [docsResult, personsResult] = await Promise.all([ + const [docsResult, personsResult, incompleteCountResult] = await Promise.all([ api.GET('/api/documents/search', { params: { query: { @@ -25,7 +25,8 @@ export async function load({ url, fetch }) { } } }), - api.GET('/api/persons') + api.GET('/api/persons'), + api.GET('/api/documents/incomplete-count') ]); if (docsResult.response.status === 401 || personsResult.response.status === 401) { @@ -39,8 +40,13 @@ export async function load({ url, fetch }) { const senderObj = allPersons.find((p) => p.id === senderId); const receiverObj = allPersons.find((p) => p.id === receiverId); + const incompleteCount = incompleteCountResult.response.ok + ? (incompleteCountResult.data?.count ?? 0) + : 0; + return { documents, + incompleteCount, initialValues: { senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '', receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : '' @@ -53,6 +59,7 @@ export async function load({ url, fetch }) { console.error('Error loading data:', e); return { documents: [], + incompleteCount: 0, initialValues: { senderName: '', receiverName: '' }, filters: { q, from, to, senderId, receiverId, tags }, error: 'Daten konnten nicht geladen werden.' as string | null diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index e5130ff1..57f40f8e 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -5,6 +5,7 @@ import { SvelteURLSearchParams } from 'svelte/reactivity'; import SearchFilterBar from './SearchFilterBar.svelte'; import DropZone from './DropZone.svelte'; import DocumentList from './DocumentList.svelte'; +import { m } from '$lib/paraglide/messages.js'; let { data } = $props(); @@ -86,5 +87,34 @@ $effect(() => { {/if} + {#if data.incompleteCount > 0} + +
+ +
+

+ {m.enrich_needs_metadata_title()} +

+

+ {m.enrich_needs_metadata_count({ count: data.incompleteCount })} +

+
+
+ + {m.enrich_needs_metadata_cta()} → + +
+ {/if} + diff --git a/frontend/src/routes/documents/[id]/edit/+page.server.ts b/frontend/src/routes/documents/[id]/edit/+page.server.ts index eaaf86e3..de11c47a 100644 --- a/frontend/src/routes/documents/[id]/edit/+page.server.ts +++ b/frontend/src/routes/documents/[id]/edit/+page.server.ts @@ -60,6 +60,53 @@ export const actions = { throw redirect(303, `/documents/${params.id}`); }, + markForReview: async ({ + params, + fetch + }: { + params: { id: string }; + fetch: typeof globalThis.fetch; + }) => { + const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + const api = createApiClient(fetch); + + // Fetch current document to preserve all existing fields + const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } }); + if (!docResult.response.ok) { + const code = (docResult.error as unknown as { code?: string })?.code; + return fail(docResult.response.status, { error: getErrorMessage(code) }); + } + + const doc = docResult.data!; + const formData = new FormData(); + if (doc.title) formData.set('title', doc.title); + if (doc.documentDate) formData.set('documentDate', doc.documentDate); + if (doc.location) formData.set('location', doc.location); + if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation); + if (doc.transcription) formData.set('transcription', doc.transcription); + if (doc.summary) formData.set('summary', doc.summary); + if (doc.sender?.id) formData.set('senderId', doc.sender.id); + if (doc.receivers?.length) { + doc.receivers.forEach((r: { id: string }) => formData.append('receiverIds', r.id)); + } + if (doc.tags?.length) { + formData.set('tags', doc.tags.map((t: { name: string }) => t.name).join(',')); + } + formData.set('metadataComplete', 'false'); + + const res = await fetch(`${baseUrl}/api/documents/${params.id}`, { + method: 'PUT', + body: formData + }); + + if (!res.ok) { + const backendError = await parseBackendError(res); + return fail(res.status, { error: getErrorMessage(backendError?.code) }); + } + + throw redirect(303, `/documents/${params.id}`); + }, + delete: async ({ params, fetch }) => { const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; diff --git a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte b/frontend/src/routes/documents/[id]/edit/SaveBar.svelte index 8fd6547b..4520eb76 100644 --- a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte +++ b/frontend/src/routes/documents/[id]/edit/SaveBar.svelte @@ -1,5 +1,6 @@ + +
+ + + + {m.enrich_list_back()} + + + +
+
+

+ {m.enrich_list_heading()} +

+ {#if count > 0} +

+ {count} + {m.enrich_list_count()} +

+ {/if} +
+ + {#if count > 0} + + {m.enrich_list_start()} + + {/if} +
+ + + {#if count === 0} +
+
+ +
+

+ {m.enrich_list_empty_heading()} +

+

+ {m.enrich_list_empty_body()} +

+
+ {:else} + + + {/if} +
diff --git a/frontend/src/routes/enrich/[id]/+page.server.ts b/frontend/src/routes/enrich/[id]/+page.server.ts new file mode 100644 index 00000000..f3e6b95d --- /dev/null +++ b/frontend/src/routes/enrich/[id]/+page.server.ts @@ -0,0 +1,109 @@ +import { error, redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage, parseBackendError } from '$lib/errors'; + +export async function load({ + params, + fetch, + locals +}: { + params: { id: string }; + fetch: typeof globalThis.fetch; + locals: App.Locals; +}) { + const canWrite = + locals.user?.groups?.some((g: { permissions: string[] }) => + g.permissions.includes('WRITE_ALL') + ) ?? false; + if (!canWrite) throw redirect(303, '/'); + + const { id } = params; + const api = createApiClient(fetch); + + const [docResult, countResult] = await Promise.all([ + api.GET('/api/documents/{id}', { params: { path: { id } } }), + api.GET('/api/documents/incomplete-count') + ]); + + if (!docResult.response.ok) { + const code = (docResult.error as unknown as { code?: string })?.code; + throw error(docResult.response.status, getErrorMessage(code)); + } + + const incompleteCount = countResult.response.ok ? (countResult.data?.count ?? 0) : 0; + + return { + document: docResult.data!, + incompleteCount + }; +} + +async function redirectToNext(id: string, fetch: typeof globalThis.fetch): Promise { + const api = createApiClient(fetch); + const nextResult = await api.GET('/api/documents/incomplete/next', { + params: { query: { excludeId: id } } + }); + + if (nextResult.response.ok && nextResult.data) { + throw redirect(303, `/enrich/${nextResult.data.id}`); + } + throw redirect(303, '/enrich/done'); +} + +export const actions = { + skip: async ({ params, fetch }: { params: { id: string }; fetch: typeof globalThis.fetch }) => { + await redirectToNext(params.id, fetch); + }, + + save: async ({ + params, + request, + fetch + }: { + params: { id: string }; + request: Request; + fetch: typeof globalThis.fetch; + }) => { + const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + const formData = await request.formData(); + + const res = await fetch(`${baseUrl}/api/documents/${params.id}`, { + method: 'PUT', + body: formData + }); + + if (!res.ok) { + const backendError = await parseBackendError(res); + return { error: getErrorMessage(backendError?.code) }; + } + + await redirectToNext(params.id, fetch); + }, + + saveAndReview: async ({ + params, + request, + fetch + }: { + params: { id: string }; + request: Request; + fetch: typeof globalThis.fetch; + }) => { + const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + const formData = await request.formData(); + formData.set('metadataComplete', 'true'); + + const res = await fetch(`${baseUrl}/api/documents/${params.id}`, { + method: 'PUT', + body: formData + }); + + if (!res.ok) { + const backendError = await parseBackendError(res); + return { error: getErrorMessage(backendError?.code) }; + } + + await redirectToNext(params.id, fetch); + } +}; diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte new file mode 100644 index 00000000..5bd896e2 --- /dev/null +++ b/frontend/src/routes/enrich/[id]/+page.svelte @@ -0,0 +1,162 @@ + + + + {doc.title || doc.originalFilename || 'Dokument'} — Anreicherung + + +
+ +
+ + + {m.enrich_back_to_list()} + + +

+ {doc.title || doc.originalFilename} +

+ +

+ {m.enrich_progress({ count: data.incompleteCount })} +

+
+ + +
+ +
+ {}} + /> +
+ + +
+ {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + + + + +
+ + +
+ + + +
+ + + + + +
+
+
+
+
diff --git a/frontend/src/routes/enrich/done/+page.svelte b/frontend/src/routes/enrich/done/+page.svelte new file mode 100644 index 00000000..22000e9c --- /dev/null +++ b/frontend/src/routes/enrich/done/+page.svelte @@ -0,0 +1,40 @@ + + +
+
+ + +

+ {m.enrich_done_heading()} +

+ +

+ {m.enrich_done_body()} +

+ + +
+
diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index ec80ec0c..09956fc7 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -23,6 +23,7 @@ const emptyData = { canAnnotate: false, filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] }, documents: [], + incompleteCount: 0, initialValues: { senderName: '', receiverName: '' }, error: null }; -- 2.49.1