From 96e4d20c03be987e6ddac2938cb7d01bf18545bc Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 11:59:03 +0200 Subject: [PATCH 01/12] feat(transcription): add existsByDocumentId block query (#697) Cheap EXISTS query backing a server-side "has a transcription" signal so read-only users can be offered the read view at first paint. Co-Authored-By: Claude Opus 4.8 --- .../TranscriptionBlockRepository.java | 2 ++ ...criptionBlockRepositoryIntegrationTest.java | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockRepository.java index 4da9ee8e..fbfab4c6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockRepository.java @@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository Date: Sun, 31 May 2026 11:59:42 +0200 Subject: [PATCH 02/12] feat(transcription): expose hasBlocks on TranscriptionBlockQueryService (#697) Domain-service wrapper over existsByDocumentId so other domains can ask "does this document have any transcription blocks?" without reaching into the repository. Co-Authored-By: Claude Opus 4.8 --- .../TranscriptionBlockQueryService.java | 4 +++ .../TranscriptionBlockQueryServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryService.java index 3daa2544..be183356 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryService.java @@ -17,6 +17,10 @@ public class TranscriptionBlockQueryService { private final TranscriptionBlockRepository blockRepository; + public boolean hasBlocks(UUID documentId) { + return blockRepository.existsByDocumentId(documentId); + } + public Map getCompletionStats(List documentIds) { if (documentIds.isEmpty()) return Map.of(); Map result = new HashMap<>(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryServiceTest.java new file mode 100644 index 00000000..28fc663a --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockQueryServiceTest.java @@ -0,0 +1,35 @@ +package org.raddatz.familienarchiv.document.transcription; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TranscriptionBlockQueryServiceTest { + + @Mock TranscriptionBlockRepository blockRepository; + @InjectMocks TranscriptionBlockQueryService queryService; + + @Test + void hasBlocks_returns_true_when_a_block_exists() { + UUID documentId = UUID.randomUUID(); + when(blockRepository.existsByDocumentId(documentId)).thenReturn(true); + + assertThat(queryService.hasBlocks(documentId)).isTrue(); + } + + @Test + void hasBlocks_returns_false_when_no_block_exists() { + UUID documentId = UUID.randomUUID(); + when(blockRepository.existsByDocumentId(documentId)).thenReturn(false); + + assertThat(queryService.hasBlocks(documentId)).isFalse(); + } +} -- 2.49.1 From 1ac2a74f30d85ac12c76041f570c78843125b0b8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:00:23 +0200 Subject: [PATCH 03/12] feat(document): add server-computed hasTranscription to detail payload (#697) getDocumentById now populates a transient hasTranscription boolean so the document detail page can gate the transcription entry control at first paint (no client store, no full block fetch, no layout shift). Co-Authored-By: Claude Opus 4.8 --- .../familienarchiv/document/Document.java | 7 +++++++ .../document/DocumentService.java | 1 + .../document/DocumentServiceTest.java | 20 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java index 7f702763..dc47f061 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java @@ -177,6 +177,13 @@ public class Document { @Builder.Default private Set trainingLabels = new HashSet<>(); + // Not persisted — computed per detail fetch so read-only users can tell at first + // paint whether there is a transcription to read (DocumentService.getDocumentById). + @Transient + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private boolean hasTranscription = false; + // The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail // endpoint sends `Cache-Control: private, max-age=31536000, immutable` // (DocumentController.getDocumentThumbnail). `immutable` is only safe because diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 8108c997..934f3417 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -943,6 +943,7 @@ public class DocumentService { Document doc = documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); tagService.resolveEffectiveColors(doc.getTags()); + doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id)); return doc; } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 04b84fba..34b5ddf0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -118,6 +118,26 @@ class DocumentServiceTest { assertThat(documentService.getDocumentById(id)).isEqualTo(doc); } + @Test + void getDocumentById_setsHasTranscriptionTrue_whenBlocksExist() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("Test").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(true); + + assertThat(documentService.getDocumentById(id).isHasTranscription()).isTrue(); + } + + @Test + void getDocumentById_setsHasTranscriptionFalse_whenNoBlocksExist() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("Test").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(false); + + assertThat(documentService.getDocumentById(id).isHasTranscription()).isFalse(); + } + // ─── updateDocument ─────────────────────────────────────────────────────── @Test -- 2.49.1 From a73fddefe3fc88d42d628b7364e03453eda501c6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:03:39 +0200 Subject: [PATCH 04/12] test(security): lock READ_ALL -> 403 on transcription/annotation writes (#697) Read-only users will soon be able to open the transcription read view, so the write endpoints become the real authorization boundary. Explicitly assert a READ_ALL-only principal is forbidden from create/update/reorder/ review block writes and annotation create/patch (the prior tests only used a no-authority principal). Co-Authored-By: Claude Opus 4.8 --- .../annotation/AnnotationControllerTest.java | 18 ++++++++++ .../TranscriptionBlockControllerTest.java | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java index 2157f6c7..5305d277 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java @@ -83,6 +83,15 @@ class AnnotationControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void createAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(ANNOTATION_JSON)) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "WRITE_ALL") void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception { @@ -190,6 +199,15 @@ class AnnotationControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void patchAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns200_withWriteAllPermission() throws Exception { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java index 5cb0769c..1df57ffe 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java @@ -159,6 +159,15 @@ class TranscriptionBlockControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void createBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + mockMvc.perform(post(URL_BASE).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "WRITE_ALL") void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception { @@ -233,6 +242,15 @@ class TranscriptionBlockControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void updateBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + mockMvc.perform(put(URL_BLOCK).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "WRITE_ALL") void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception { @@ -363,6 +381,15 @@ class TranscriptionBlockControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void reorderBlocks_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + mockMvc.perform(put(URL_REORDER).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "WRITE_ALL") void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception { @@ -440,6 +467,14 @@ class TranscriptionBlockControllerTest { .andExpect(jsonPath("$.reviewed").value(true)); } + @Test + @WithMockUser(authorities = "READ_ALL") + void reviewBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review", + DOC_ID, BLOCK_ID).with(csrf())) + .andExpect(status().isForbidden()); + } + // ─── PUT .../review-all ─────────────────────────────────────────────────── private static final String URL_REVIEW_ALL = URL_BASE + "/review-all"; -- 2.49.1 From 4e218dc2b87990d762876b644ddfbeedad4cda4a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:08:30 +0200 Subject: [PATCH 05/12] chore(api): regenerate Document type with hasTranscription (#697) Mirrors the new server-computed boolean on the document detail payload so the frontend can gate the transcription entry control at first paint. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/generated/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 5b059dd5..ff7562d8 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1758,6 +1758,7 @@ export interface components { sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; + hasTranscription: boolean; thumbnailUrl?: string; }; PersonMention: { -- 2.49.1 From d1b24c905f0ef7da7e24264194c53828f03fb76c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:09:07 +0200 Subject: [PATCH 06/12] i18n(transcription): add reader read-label and panel title strings (#697) transcription_read_label ("Transkription lesen") for the read-only entry control and transcription_panel_title ("Transkription") for the plain header readers see instead of the Lesen/Bearbeiten toggle. Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + .../lib/document/DocumentMobileMenu.svelte | 13 +++-- .../DocumentMobileMenu.svelte.test.ts | 38 ++++++++++++++ .../src/lib/document/DocumentTopBar.svelte | 3 ++ .../lib/document/DocumentTopBarActions.svelte | 13 +++-- .../DocumentTopBarActions.svelte.test.ts | 51 +++++++++++++++++++ 8 files changed, 118 insertions(+), 6 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 37bd55cd..14269eed 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -603,6 +603,8 @@ "doc_details_no_tags": "Keine Schlagwörter zugeordnet", "doc_details_more_receivers": "+{count} weitere", "transcription_mode_label": "Transkribieren", + "transcription_read_label": "Transkription lesen", + "transcription_panel_title": "Transkription", "transcription_mode_stop": "Fertig", "transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen", "transcription_block_save_saving": "Speichere...", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0f5f862f..b7de0948 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -603,6 +603,8 @@ "doc_details_no_tags": "No tags assigned", "doc_details_more_receivers": "+{count} more", "transcription_mode_label": "Transcribe", + "transcription_read_label": "Read transcription", + "transcription_panel_title": "Transcription", "transcription_mode_stop": "Done", "transcription_block_placeholder": "Type text — use @name to link a person from the archive", "transcription_block_save_saving": "Saving...", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3d9e92ab..ee584c40 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -603,6 +603,8 @@ "doc_details_no_tags": "No hay etiquetas asignadas", "doc_details_more_receivers": "+{count} más", "transcription_mode_label": "Transcribir", + "transcription_read_label": "Leer transcripción", + "transcription_panel_title": "Transcripción", "transcription_mode_stop": "Listo", "transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo", "transcription_block_save_saving": "Guardando...", diff --git a/frontend/src/lib/document/DocumentMobileMenu.svelte b/frontend/src/lib/document/DocumentMobileMenu.svelte index c2892bbd..42c6df5f 100644 --- a/frontend/src/lib/document/DocumentMobileMenu.svelte +++ b/frontend/src/lib/document/DocumentMobileMenu.svelte @@ -5,6 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside'; type Props = { canWrite: boolean; isPdf: boolean; + hasTranscription: boolean; transcribeMode: boolean; filePath?: string | null; originalFilename?: string | null; @@ -14,12 +15,18 @@ type Props = { let { canWrite, isPdf, + hasTranscription, transcribeMode = $bindable(), filePath = null, originalFilename = null, fileUrl }: Props = $props(); +const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf); +const transcriptionLabel = $derived( + canWrite ? m.transcription_mode_label() : m.transcription_read_label() +); + let mobileMenuOpen = $state(false); function startTranscribe() { @@ -50,10 +57,10 @@ function startTranscribe() { role="menu" class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg" > - {#if canWrite && isPdf && !transcribeMode} + {#if canOpenTranscription && !transcribeMode} {/if} diff --git a/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts b/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts index 9b561aa5..c3c3f0a0 100644 --- a/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts +++ b/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts @@ -8,6 +8,7 @@ afterEach(cleanup); const baseProps = { canWrite: false, isPdf: false, + hasTranscription: false, transcribeMode: false, filePath: null as string | null, originalFilename: 'brief.pdf' as string | null, @@ -49,6 +50,43 @@ describe('DocumentMobileMenu', () => { await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible(); }); + it('shows no transcription action for a read-only user when no transcription exists', async () => { + render(DocumentMobileMenu, { + props: { + ...baseProps, + canWrite: false, + isPdf: true, + hasTranscription: false, + filePath: 'docs/x.pdf' + } + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect + .element(page.getByRole('button', { name: /transkribieren/i })) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /transkription lesen/i })) + .not.toBeInTheDocument(); + }); + + it('shows the read-transcription action for a read-only user when a transcription exists', async () => { + render(DocumentMobileMenu, { + props: { + ...baseProps, + canWrite: false, + isPdf: true, + hasTranscription: true, + filePath: 'docs/x.pdf' + } + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect.element(page.getByRole('button', { name: /transkription lesen/i })).toBeVisible(); + }); + it('hides the transcribe action when already in transcribeMode', async () => { render(DocumentMobileMenu, { props: { diff --git a/frontend/src/lib/document/DocumentTopBar.svelte b/frontend/src/lib/document/DocumentTopBar.svelte index 7766e376..0c935f46 100644 --- a/frontend/src/lib/document/DocumentTopBar.svelte +++ b/frontend/src/lib/document/DocumentTopBar.svelte @@ -28,6 +28,7 @@ type Doc = { location?: string | null; status?: string | null; tags?: Tag[] | null; + hasTranscription?: boolean; }; type GeschichteSummary = { @@ -132,6 +133,7 @@ const overflowPersons = $derived(receivers.slice(2)); documentId={doc.id} canWrite={canWrite} isPdf={!!isPdf} + hasTranscription={!!doc.hasTranscription} bind:transcribeMode={transcribeMode} filePath={doc.filePath} originalFilename={doc.originalFilename} @@ -143,6 +145,7 @@ const overflowPersons = $derived(receivers.slice(2)); -{#if canWrite && isPdf && !transcribeMode} +{#if canOpenTranscription && !transcribeMode} {/if} diff --git a/frontend/src/lib/document/DocumentTopBarActions.svelte.test.ts b/frontend/src/lib/document/DocumentTopBarActions.svelte.test.ts index 280a3245..1b59d0e2 100644 --- a/frontend/src/lib/document/DocumentTopBarActions.svelte.test.ts +++ b/frontend/src/lib/document/DocumentTopBarActions.svelte.test.ts @@ -9,6 +9,7 @@ const baseProps = { documentId: 'd1', canWrite: false, isPdf: false, + hasTranscription: false, transcribeMode: false, filePath: null as string | null, originalFilename: 'brief.pdf' as string | null, @@ -34,6 +35,56 @@ describe('DocumentTopBarActions', () => { await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible(); }); + it('shows no transcription control for a read-only user when no transcription exists', async () => { + render(DocumentTopBarActions, { + props: { + ...baseProps, + canWrite: false, + isPdf: true, + hasTranscription: false, + filePath: 'docs/x.pdf' + } + }); + + await expect + .element(page.getByRole('button', { name: /transkribieren/i })) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /transkription lesen/i })) + .not.toBeInTheDocument(); + }); + + it('shows the read-transcription button for a read-only user when a transcription exists', async () => { + render(DocumentTopBarActions, { + props: { + ...baseProps, + canWrite: false, + isPdf: true, + hasTranscription: true, + filePath: 'docs/x.pdf' + } + }); + + await expect.element(page.getByRole('button', { name: /transkription lesen/i })).toBeVisible(); + await expect + .element(page.getByRole('button', { name: /^transkribieren$/i })) + .not.toBeInTheDocument(); + }); + + it('shows the transcribe (edit) label for a writer regardless of hasTranscription', async () => { + render(DocumentTopBarActions, { + props: { + ...baseProps, + canWrite: true, + isPdf: true, + hasTranscription: false, + filePath: 'docs/x.pdf' + } + }); + + await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible(); + }); + it('omits the transcribe button when not a PDF', async () => { render(DocumentTopBarActions, { props: { ...baseProps, canWrite: true, isPdf: false, filePath: 'docs/x.jpg' } -- 2.49.1 From c5d62bef712c29079003a09e51b415510b087511 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:19:36 +0200 Subject: [PATCH 07/12] feat(ui): show read-only transcription header without an edit tab (#697) TranscriptionPanelHeader gains a canEdit prop (default true). Editors keep the Lesen/Bearbeiten segmented toggle; read-only users get a plain "Transkription" heading instead of a lone single-option pill, while the "N Abschnitte" status line stays visible. Co-Authored-By: Claude Opus 4.8 --- .../TranscriptionPanelHeader.svelte | 75 +++++++++++-------- .../TranscriptionPanelHeader.svelte.test.ts | 21 ++++++ 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte b/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte index a7c91784..f6c6ee07 100644 --- a/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte @@ -8,11 +8,20 @@ type Props = { hasBlocks: boolean; blockCount: number; lastEditedAt: string | null; + canEdit?: boolean; onModeChange: (mode: 'read' | 'edit') => void; onClose: () => void; }; -let { mode, hasBlocks, blockCount, lastEditedAt, onModeChange, onClose }: Props = $props(); +let { + mode, + hasBlocks, + blockCount, + lastEditedAt, + canEdit = true, + onModeChange, + onClose +}: Props = $props(); const formattedDate = $derived( lastEditedAt @@ -34,37 +43,41 @@ function handleReadClick() {
- -
-
- - + + {#if canEdit} +
+
+ + +
+ +

{m.transcription_mode_help_body()}

+
- -

{m.transcription_mode_help_body()}

-
-
+ {:else} +

{m.transcription_panel_title()}

+ {/if}
-- 2.49.1 From bee309e40cb0d1de804632155f3aae91906e13b3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:21:39 +0200 Subject: [PATCH 09/12] test(e2e): read-only user reads a transcription, no edit affordances (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI happy path: seed a PDF document with a transcription block as admin, then as the READ_ALL "reader" open it — assert the "Transkription lesen" control, the read text, a plain "Transkription" header, and the absence of the Lesen/Bearbeiten tabs (panel cannot switch to edit). Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/transcription-read-only.spec.ts | 98 ++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 frontend/e2e/transcription-read-only.spec.ts diff --git a/frontend/e2e/transcription-read-only.spec.ts b/frontend/e2e/transcription-read-only.spec.ts new file mode 100644 index 00000000..28938617 --- /dev/null +++ b/frontend/e2e/transcription-read-only.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { login } from './helpers/auth'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E for issue #697 — read-only users can read an existing transcription. + * + * Setup runs as admin (default storage state): a PDF document with one + * transcription block, so hasTranscription is true. The assertions run in a + * fresh context logged in as the seeded READ_ALL-only "reader": they can open + * the read view but see no edit tab and no per-block edit controls, and the + * panel cannot be switched to edit. + */ + +let docId: string; +let docHref: string; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Read-only user reads an existing transcription', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + const uniqueSuffix = Date.now(); + + const docRes = await request.post('/api/documents', { + multipart: { + title: `E2E Read-only Transcription ${uniqueSuffix}`, + documentDate: '1945-05-08' + } + }); + if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`); + docId = (await docRes.json()).id; + docHref = `${baseURL}/documents/${docId}`; + + await request.put(`/api/documents/${docId}`, { + multipart: { + title: `E2E Read-only Transcription ${uniqueSuffix}`, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + + const annRes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' } + }); + if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`); + + const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.5, + height: 0.1, + text: 'Liebe Mutter, viele Grüße vom Mai 1945', + label: null + } + }); + if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`); + }); + + test.afterAll(async ({ request }) => { + if (docId) await request.delete(`/api/documents/${docId}`); + }); + + test('reader opens the read view with no edit tab or edit controls', async ({ browser }) => { + const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const page = await context.newPage(); + try { + await login(page, 'reader', 'reader123'); + await page.goto(docHref); + + // Reader entry control is the read label, not "Transkribieren". + const readButton = page.getByRole('button', { name: /Transkription lesen/i }); + await expect(readButton).toBeVisible({ timeout: 5000 }); + await readButton.click(); + + // Read view shows the transcription text. + await expect(page.getByText(/Mai 1945/)).toBeVisible({ timeout: 5000 }); + + // Header is a plain "Transkription" label, not a Lesen/Bearbeiten toggle. + await expect(page.getByRole('heading', { name: /^Transkription$/i })).toBeVisible(); + await expect(page.getByTestId('mode-edit')).toHaveCount(0); + await expect(page.getByTestId('mode-read')).toHaveCount(0); + } finally { + await context.close(); + } + }); +}); -- 2.49.1 From d1c9881b67286b8516880ce2ebba5ab81100f73e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:42:14 +0200 Subject: [PATCH 10/12] refactor(document): compute hasTranscription only on the detail path (#697) Move the hasTranscription existence query out of the shared getDocumentById into a dedicated getDocumentDetail used solely by GET /api/documents/{id}. The flag is only consumed by the detail page, so the extra EXISTS query no longer runs for the many internal getDocumentById callers (e.g. the Geschichte resolve loop and the dashboard resume path). Behaviour of the detail endpoint is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../document/DocumentController.java | 2 +- .../document/DocumentService.java | 12 ++++++++++++ .../document/DocumentServiceTest.java | 19 +++++++++++++++---- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java index daaa96c5..b77b57b7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java @@ -138,7 +138,7 @@ public class DocumentController { // --- METADATA --- @GetMapping("/{id}") public Document getDocument(@PathVariable UUID id) { - return documentService.getDocumentById(id); + return documentService.getDocumentDetail(id); } @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 934f3417..93570e49 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -943,6 +943,18 @@ public class DocumentService { Document doc = documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); tagService.resolveEffectiveColors(doc.getTags()); + return doc; + } + + /** + * Loads a document for the detail view, additionally flagging whether it has any + * transcription to read. Kept separate from {@link #getDocumentById} so the cheap + * existence query only runs for the single-document detail endpoint, not for the + * many internal callers that never read the flag. + */ + @Transactional(readOnly = true) + public Document getDocumentDetail(UUID id) { + Document doc = getDocumentById(id); doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id)); return doc; } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 34b5ddf0..ccf162f9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -119,23 +119,34 @@ class DocumentServiceTest { } @Test - void getDocumentById_setsHasTranscriptionTrue_whenBlocksExist() { + void getDocumentById_doesNotQueryTranscription() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("Test").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + documentService.getDocumentById(id); + + verifyNoInteractions(transcriptionBlockQueryService); + } + + @Test + void getDocumentDetail_setsHasTranscriptionTrue_whenBlocksExist() { UUID id = UUID.randomUUID(); Document doc = Document.builder().id(id).title("Test").build(); when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(true); - assertThat(documentService.getDocumentById(id).isHasTranscription()).isTrue(); + assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isTrue(); } @Test - void getDocumentById_setsHasTranscriptionFalse_whenNoBlocksExist() { + void getDocumentDetail_setsHasTranscriptionFalse_whenNoBlocksExist() { UUID id = UUID.randomUUID(); Document doc = Document.builder().id(id).title("Test").build(); when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(false); - assertThat(documentService.getDocumentById(id).isHasTranscription()).isFalse(); + assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isFalse(); } // ─── updateDocument ─────────────────────────────────────────────────────── -- 2.49.1 From beaf86558d89b2798262c94d8a9225a6634e5289 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:43:52 +0200 Subject: [PATCH 11/12] test(security): lock READ_ALL -> 403 on comment-write endpoints (#697) Round out the "read-only users can't write anything" boundary: a READ_ALL principal is forbidden from posting a block comment, replying, and editing a comment (the prior tests only used a no-authority principal for create). Co-Authored-By: Claude Opus 4.8 --- .../comment/CommentControllerTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java index 473b1a7a..7b047617 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java @@ -94,6 +94,15 @@ class CommentControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void postBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + UUID blockId = UUID.randomUUID(); + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "ANNOTATE_ALL") void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception { @@ -142,6 +151,16 @@ class CommentControllerTest { .andExpect(status().isUnauthorized()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void replyToBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + UUID blockId = UUID.randomUUID(); + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "ANNOTATE_ALL") void replyToBlockComment_returns201_whenHasPermission() throws Exception { @@ -181,6 +200,14 @@ class CommentControllerTest { .andExpect(status().isUnauthorized()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void editComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "ANNOTATE_ALL") void editComment_returns200_whenHasPermission() throws Exception { -- 2.49.1 From bd3c1ced1d7aaecd701d8d72cdb6bf9365679a97 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:58:38 +0200 Subject: [PATCH 12/12] test(document): run OCR-status page tests as a writer (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OCR status check is now gated behind canWrite (readers do no write-path work), so the two OCR-status page tests must render as a writer — OCR is a writer action. Without canWrite the status check never fires and the "OCR läuft" spinner never mounts. Fixes the CI regression introduced by confining read-only users to the read view. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/documents/[id]/page.svelte.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/documents/[id]/page.svelte.test.ts b/frontend/src/routes/documents/[id]/page.svelte.test.ts index d3ee08f0..c34b26f5 100644 --- a/frontend/src/routes/documents/[id]/page.svelte.test.ts +++ b/frontend/src/routes/documents/[id]/page.svelte.test.ts @@ -360,7 +360,7 @@ describe('documents/[id] page', () => { try { mockPage.url = new URL('http://localhost/documents/d-ocr-fail?task=transcribe'); render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-fail' } }) } + props: { data: baseData({ canWrite: true, document: { ...baseDoc, id: 'd-ocr-fail' } }) } }); await vi.waitFor(() => { expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull(); @@ -391,7 +391,7 @@ describe('documents/[id] page', () => { try { mockPage.url = new URL('http://localhost/documents/d-ocr-run?task=transcribe'); render(DocumentDetailPage, { - props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-run' } }) } + props: { data: baseData({ canWrite: true, document: { ...baseDoc, id: 'd-ocr-run' } }) } }); await expect.element(browserPage.getByText('OCR läuft')).toBeVisible(); } finally { -- 2.49.1