feat(transcription): let read-only users read transcriptions (read tab only, no edit) #697
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
The transcription panel on the document detail page (
frontend/src/routes/documents/[id]/+page.svelte) is today entirely gated behindcanWrite. AREAD_ALL(read-only) user — typically a younger family member reading on a phone — cannot open a transcription at all, even when one already exists. That's wrong: the whole point of the crowd-transcription effort is that readers can read the finished text.Desired behaviour:
The architecture already supports the rendering: a read-only path (
TranscriptionReadView), apanelMode: 'read' | 'edit'switch, and ahasBlocksflag all exist. The work is (1) make a reliable "has a transcription" signal available at first paint, (2) widen the entry gate, and (3) suppress all edit affordances when!canWrite.Current code
Entry control —
frontend/src/lib/document/DocumentTopBarActions.svelte:25→ Readers never get a button. The component receives
canWrite,isPdf,transcribeModebut no "does a transcription exist" flag.Prop chain (note: two render sites)
+page.svelte→DocumentTopBar.svelte(Propsat:40; derivesisPdfat:62) →DocumentTopBarActions.svelte.DocumentTopBarrendersDocumentTopBarActionstwice — desktop (:131) and mobile (:143). A new prop must reach both.⚠️ Why
hasTranscriptionmust come from the servertranscription.hasBlocksis a client store (useTranscriptionBlocks.svelte.ts:56,blocks.length > 0) populated by a client-sidefetch('/api/documents/{id}/transcription-blocks').onMount(+page.svelte:167) does not calltranscription.load()— the load fires only from interaction/PDF paths — anddocuments/[id]/+page.server.tscarries no transcription data. So gating the entry button onhasBlocksmeans it isfalseat first paint and would pop in late (layout shift in the top bar) or never appear in the pure read path. Gate on a server-provided flag instead.Panel render —
+page.svelteTabs —
frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelteRenders a segmented
Lesen | Bearbeitentoggle (data-testid="mode-read"/mode-edit, lines 40-62). No prop to hide the Edit tab yet.Changes
1. Server-sourced
hasTranscription(decision: any block)In
documents/[id]/+page.server.ts, expose a cheap booleanhasTranscription(aCOUNT/EXISTSon transcription blocks, not a full block fetch — prefer reusing a field already on the document detail payload if one exists). Definition: true when the document has ≥1 transcription block of any kind (noreviewedrequirement). Return it in the page data.2. Thread the flag down
+page.sveltepasseshasTranscription→DocumentTopBar(add toProps) → bothDocumentTopBarActionssites (:131,:143).3. Widen the entry gate —
DocumentTopBarActions.svelteAdd prop
hasTranscription: boolean. Extract the condition to a$derived(no logic in the template):hasTranscription, label "Transkription lesen".4. Force read mode + hide the Edit tab —
+page.sveltecanWrite(or a derivedcanEdit) intoTranscriptionPanelHeader.onModeChangeso a reader can never be flipped to'edit'when!canWrite. The{:else}(TranscriptionEditView) branch is then unreachable for readers (panelModestays'read'; defaults at:74/:151already resolve to'read'when blocks exist).5. Hide the Edit tab; render a plain label —
TranscriptionPanelHeader.svelte(decision: plain label)Add
canEdit: boolean(defaulttrue) toProps. WhencanEditis true, render the existingLesen | Bearbeitensegmented toggle. WhencanEditis false, render a plain heading/label ("Transkription",font-sans text-ink, ≥16px) instead of a lone single-option pill. Keep the status line ("N Abschnitte · zuletzt bearbeitet …") visible for readers — it's informational, not an edit control. PasscanEdit={canWrite}from the parent.6. Verify the reader path exposes zero edits
TranscriptionReadView— confirm no comment-compose, review toggle, or annotation create/delete.annotationsDimmedis set (+page.svelte:257) — confirm the overlay ispointer-events-none(non-interactive), not merely dimmed, so a reader can't click-drag a new annotation.canComment={canWrite}(+page.svelte:348) keeps compose hidden for readers — leave as-is for this issue.i18n
Add to
frontend/messages/{de,en,es}.json(others already exist:mode_read,mode_edit,transcription_mode_label):No backend model/OpenAPI change → no
npm run generate:api.Tests
Extend existing files — do not create new ones:
TranscriptionPanelHeader.svelte.test.ts(already covershasBlocks):canEdit=true→ both tabs present;canEdit=false→mode-editabsent, plain "Transkription" label present, status line present.DocumentTopBarActions.svelte.test.ts: (canWrite F, hasTx F) → no button; (canWrite F, hasTx T) → button with read label; (canWrite T, isPdf T) → button with edit label. WithhasTranscriptionas a prop these are deterministic (no async store to stub).GET /api/documents/{id}/transcription-blocksreturns 200; READ_ALL → block/annotation write endpoints (POST/PATCH/DELETE, review-toggle) return 403. Forcing read mode in the UI is not the control; the endpoints are.mode-editabsent, no per-block edit/delete/review controls, panel cannot switch to edit.Acceptance criteria
:288). Entry control ≥44px touch target.Resolved decisions (from multi-persona review)
blockCount > 0), noreviewedrequirement.hasTranscriptionsourced from the server load as a cheapexists/count (Markus: SSR correctness; Leonie: no layout shift; Tobias: cheap query) — not the lazy client store.Related
fix(ui): hide write/edit controls from READ_ALL users— the simple gates. This is the complex half of the same goal.Implemented in PR #700 (branch
feat/issue-697-readers-read-transcriptions), TDD throughout — red test → minimum green → atomic commit.Resolved-decision alignment:
hasTranscription= any block exists; single read tab → plain "Transkription" label; reader entry label "Transkription lesen"; signal sourced from the server load (a cheapexistsByDocumentIdon the document detail payload — confirmed with the maintainer over the lazy client store).Commits
feat(transcription): add existsByDocumentId block query— cheap EXISTS inTranscriptionBlockRepository(+ integration test).feat(transcription): expose hasBlocks on TranscriptionBlockQueryService(+ unit test).feat(document): add server-computed hasTranscription to detail payload—@Transientfield populated inDocumentService.getDocumentById(+ unit test).test(security): lock READ_ALL -> 403 on transcription/annotation writes— explicit READ_ALL boundary on create/update/reorder/review + annotation create/patch.chore(api): regenerate Document type with hasTranscription.i18n(transcription): add reader read-label and panel title strings(de/en/es).feat(ui): let read-only users open a transcription to read— widened entry gate on desktop top-bar and mobile menu, dynamic read/edit label, threadeddoc.hasTranscription.feat(ui): show read-only transcription header without an edit tab—canEditonTranscriptionPanelHeader; plain "Transkription" heading + status line.feat(ui): confine read-only users to the transcription read view— guardedonModeChange, read default for readers,canAnnotate={canWrite}threaded toPdfViewerso the draw/delete/resize overlay is off, OCR status check skipped.test(e2e): read-only user reads a transcription, no edit affordances— CI happy path.Acceptance criteria — all four scenarios covered: reader-with-transcription sees the read control + plain label + no edit/delete/review; reader-without-transcription sees nothing; writer unchanged; entry control present at first paint (server-sourced, no pop-in).
Verification: 239 backend tests green (incl. Testcontainers repo test);
./mvnw clean packagebuilds;npm run checkclean for all touched files; frontend component/E2E specs run in CI per project convention. Mobile 320px / 44px touch target inherit the existing top-bar layout (unchanged).Note on §6 wording: the PDF overlay is made non-interactive for readers via the existing
canDrawgate (nowtranscribeMode && canAnnotate), which is the actual authorization path and also disables delete/resize — cleaner than a CSS-onlypointer-events-none.