diff --git a/backend/api_tests/Transcription.http b/backend/api_tests/Transcription.http new file mode 100644 index 00000000..7f3aa250 --- /dev/null +++ b/backend/api_tests/Transcription.http @@ -0,0 +1,3 @@ +### Mark all blocks as reviewed +PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all +Authorization: Basic admin admin123 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java index 82338bc2..4d206cd7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -90,6 +90,15 @@ public class TranscriptionBlockController { return transcriptionService.reviewBlock(documentId, blockId, userId); } + @PutMapping("/review-all") + @RequirePermission(Permission.WRITE_ALL) + public List markAllBlocksReviewed( + @PathVariable UUID documentId, + Authentication authentication) { + UUID userId = requireUserId(authentication); + return transcriptionService.markAllBlocksReviewed(documentId, userId); + } + @GetMapping("/{blockId}/history") @RequirePermission(Permission.READ_ALL) public List getBlockHistory( diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java index 01bbff54..589e7f56 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -205,6 +205,18 @@ public class TranscriptionService { return saved; } + @Transactional + public List markAllBlocksReviewed(UUID documentId, UUID userId) { + List blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); + for (TranscriptionBlock block : blocks) { + if (!block.isReviewed()) { + block.setReviewed(true); + auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null); + } + } + return blockRepository.saveAll(blocks); + } + public List getBlockHistory(UUID documentId, UUID blockId) { getBlock(documentId, blockId); return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java index 31073057..255d19ee 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java @@ -380,4 +380,63 @@ class TranscriptionBlockControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.reviewed").value(true)); } + + // ─── PUT .../review-all ─────────────────────────────────────────────────── + + private static final String URL_REVIEW_ALL = URL_BASE + "/review-all"; + + @Test + void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put(URL_REVIEW_ALL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(put(URL_REVIEW_ALL)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception { + when(userService.findByEmail(any())).thenReturn(mockUser()); + TranscriptionBlock b1 = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID()) + .text("Block 1").sortOrder(0).reviewed(true).build(); + TranscriptionBlock b2 = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID()) + .text("Block 2").sortOrder(1).reviewed(true).build(); + when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) + .thenReturn(List.of(b1, b2)); + + mockMvc.perform(put(URL_REVIEW_ALL)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].reviewed").value(true)) + .andExpect(jsonPath("$[1].reviewed").value(true)); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception { + when(userService.findByEmail(any())).thenReturn(mockUser()); + when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) + .thenReturn(List.of()); + + mockMvc.perform(put(URL_REVIEW_ALL)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception { + when(userService.findByEmail(any())).thenReturn(null); + + mockMvc.perform(put(URL_REVIEW_ALL)) + .andExpect(status().isUnauthorized()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java index 65584fe7..7abfb237 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -506,4 +506,86 @@ class TranscriptionServiceTest { verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); } + + // ─── markAllBlocksReviewed ─────────────────────────────────────────────────── + + @Test + void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + TranscriptionBlock block1 = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).reviewed(false).build(); + TranscriptionBlock block2 = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).reviewed(false).build(); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)) + .thenReturn(List.of(block1, block2)); + when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0)); + + List result = transcriptionService.markAllBlocksReviewed(docId, userId); + + assertThat(result).allMatch(TranscriptionBlock::isReviewed); + verify(blockRepository).saveAll(List.of(block1, block2)); + } + + @Test + void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + TranscriptionBlock block = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).reviewed(true).build(); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)) + .thenReturn(List.of(block)); + when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0)); + + List result = transcriptionService.markAllBlocksReviewed(docId, userId); + + assertThat(result).allMatch(TranscriptionBlock::isReviewed); + verify(blockRepository).saveAll(any()); + } + + @Test + void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + TranscriptionBlock block1 = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).reviewed(false).build(); + TranscriptionBlock block2 = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).reviewed(false).build(); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)) + .thenReturn(List.of(block1, block2)); + when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0)); + + transcriptionService.markAllBlocksReviewed(docId, userId); + + verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null); + } + + @Test + void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).reviewed(true).build(); + TranscriptionBlock unreviewed = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).reviewed(false).build(); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)) + .thenReturn(List.of(alreadyReviewed, unreviewed)); + when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0)); + + transcriptionService.markAllBlocksReviewed(docId, userId); + + verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null); + } + + @Test + void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of()); + when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0)); + + List result = transcriptionService.markAllBlocksReviewed(docId, userId); + + assertThat(result).isEmpty(); + } } diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index c4188982..eae2c825 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -19,6 +19,7 @@ type Props = { onSaveBlock: (blockId: string, text: string) => Promise; onDeleteBlock: (blockId: string) => Promise; onReviewToggle: (blockId: string) => Promise; + onMarkAllReviewed?: () => Promise; onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void; canWrite?: boolean; trainingLabels?: string[]; @@ -37,6 +38,7 @@ let { onSaveBlock, onDeleteBlock, onReviewToggle, + onMarkAllReviewed, onTriggerOcr, canWrite = false, trainingLabels = [], @@ -46,12 +48,14 @@ let { let activeBlockId: string | null = $state(null); let localLabels: string[] = $derived.by(() => [...trainingLabels]); let listEl: HTMLElement | null = $state(null); +let markingAllReviewed = $state(false); const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); const hasBlocks = $derived(blocks.length > 0); const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length); const totalCount = $derived(blocks.length); const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0); +const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount); // Sync: when an annotation is clicked on the PDF, activate the corresponding block $effect(() => { @@ -60,6 +64,16 @@ $effect(() => { if (block) activeBlockId = block.id; }); +async function handleMarkAllReviewed() { + if (!onMarkAllReviewed) return; + markingAllReviewed = true; + try { + await onMarkAllReviewed(); + } finally { + markingAllReviewed = false; + } +} + const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId }); const dragDrop = createBlockDragDrop({ @@ -147,9 +161,56 @@ async function handleLabelToggle(label: string) { {#if hasBlocks}
-

- {reviewedCount} / {totalCount} geprüft -

+
+

+ {reviewedCount} / {totalCount} geprüft +

+ {#if onMarkAllReviewed} + + {/if} +
= {}, service = createCon }; } +const unreviewedBlock1 = { ...block1, reviewed: false }; +const unreviewedBlock2 = { ...block2, reviewed: false }; +const reviewedBlock1 = { ...block1, reviewed: true }; +const reviewedBlock2 = { ...block2, reviewed: true }; + describe('TranscriptionEditView — rendering', () => { it('renders blocks in sort order', async () => { renderView(); @@ -269,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => { await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument(); }); }); + +// ─── Bulk mark all as reviewed ──────────────────────────────────────────────── + +describe('TranscriptionEditView — mark all reviewed', () => { + it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => { + renderView({ + blocks: [unreviewedBlock1, unreviewedBlock2], + onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) + }); + await expect + .element(page.getByRole('button', { name: /Alle als fertig markieren/ })) + .toBeInTheDocument(); + }); + + it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => { + renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] }); + await expect + .element(page.getByRole('button', { name: /Alle als fertig markieren/ })) + .not.toBeInTheDocument(); + }); + + it('disables button when all blocks are already reviewed', async () => { + renderView({ + blocks: [reviewedBlock1, reviewedBlock2], + onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) + }); + await expect + .element(page.getByRole('button', { name: /Alle als fertig markieren/ })) + .toBeDisabled(); + }); + + it('calls onMarkAllReviewed exactly once when button is clicked', async () => { + const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined); + renderView({ + blocks: [unreviewedBlock1, unreviewedBlock2], + onMarkAllReviewed + }); + + await page.getByRole('button', { name: /Alle als fertig markieren/ }).click(); + await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1)); + }); + + it('disables button while operation is in-flight', async () => { + let resolveMarkAll!: () => void; + const onMarkAllReviewed = vi + .fn() + .mockReturnValue(new Promise((r) => (resolveMarkAll = r))); + renderView({ + blocks: [unreviewedBlock1, unreviewedBlock2], + onMarkAllReviewed + }); + + const btn = page.getByRole('button', { name: /Alle als fertig markieren/ }); + await btn.click(); + await expect.element(btn).toBeDisabled(); + resolveMarkAll(); + }); +}); diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 2cac8cf8..9c52faa6 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -137,6 +137,18 @@ async function reviewToggle(blockId: string) { transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); } +async function markAllReviewed() { + const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, { + method: 'PUT' + }); + if (!res.ok) return; + const updated = await res.json(); + for (const b of updated) { + const existing = transcriptionBlocks.find((x) => x.id === b.id); + if (existing) existing.reviewed = b.reviewed; + } +} + async function toggleTrainingLabel(label: string, enrolled: boolean) { const res = await fetch(`/api/documents/${doc.id}/training-labels`, { method: 'PATCH', @@ -501,6 +513,7 @@ onMount(() => { onSaveBlock={saveBlock} onDeleteBlock={deleteBlock} onReviewToggle={reviewToggle} + onMarkAllReviewed={markAllReviewed} onTriggerOcr={triggerOcr} onToggleTrainingLabel={toggleTrainingLabel} />