feat(transcription): let read-only users read transcriptions (read tab only, no edit) #697

Closed
opened 2026-05-31 10:04:59 +02:00 by marcel · 1 comment
Owner

Context

The transcription panel on the document detail page (frontend/src/routes/documents/[id]/+page.svelte) is today entirely gated behind canWrite. A READ_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:

  • READ_ALL user, document has a transcription → can open the panel, sees only the read view; no edit tab, no per-block edit/delete/review controls, no "Transkribieren" affordance.
  • READ_ALL user, no transcription → the entry control is hidden entirely (nothing to read).
  • WRITE_ALL user → unchanged: read + edit tabs, full controls.

The architecture already supports the rendering: a read-only path (TranscriptionReadView), a panelMode: 'read' | 'edit' switch, and a hasBlocks flag 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.

Review outcome (decisions resolved — see end of issue): "has a transcription to read" = any block exists (blockCount > 0); the single read tab renders as a plain label, not a lone pill; the reader entry label is "Transkription lesen". The hasTranscription signal is sourced from the server load, not the lazy client store.


Current code

Entry control — frontend/src/lib/document/DocumentTopBarActions.svelte:25

{#if canWrite && isPdf && !transcribeMode}
    <button onclick={() => (transcribeMode = true)} ...>{m.transcription_mode_label()}</button>
{/if}

→ Readers never get a button. The component receives canWrite, isPdf, transcribeMode but no "does a transcription exist" flag.

Prop chain (note: two render sites)

+page.svelteDocumentTopBar.svelte (Props at :40; derives isPdf at :62) → DocumentTopBarActions.svelte. DocumentTopBar renders DocumentTopBarActions twice — desktop (:131) and mobile (:143). A new prop must reach both.

⚠️ Why hasTranscription must come from the server

transcription.hasBlocks is a client store (useTranscriptionBlocks.svelte.ts:56, blocks.length > 0) populated by a client-side fetch('/api/documents/{id}/transcription-blocks'). onMount (+page.svelte:167) does not call transcription.load() — the load fires only from interaction/PDF paths — and documents/[id]/+page.server.ts carries no transcription data. So gating the entry button on hasBlocks means it is false at 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.svelte

let panelMode = $state<'read' | 'edit'>('read');               // :40
panelMode = transcription.hasBlocks ? 'read' : 'edit';          // :74, :151 defaults

{#if transcribeMode}
    <TranscriptionPanelHeader mode={panelMode} hasBlocks={...} onModeChange={...} />   // :291
    ...
    {:else if panelMode === 'read'}
        <TranscriptionReadView blocks={...} />   // :338 — read-only, no edit controls ✅
    {:else}
        <TranscriptionEditView canWrite={canWrite} ... />   // :345 — edit controls
    {/if}
{/if}

Tabs — frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte

Renders a segmented Lesen | Bearbeiten toggle (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 boolean hasTranscription (a COUNT/EXISTS on 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 (no reviewed requirement). Return it in the page data.

2. Thread the flag down

+page.svelte passes hasTranscriptionDocumentTopBar (add to Props) → both DocumentTopBarActions sites (:131, :143).

3. Widen the entry gate — DocumentTopBarActions.svelte

Add prop hasTranscription: boolean. Extract the condition to a $derived (no logic in the template):

const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf);
{#if canOpenTranscription && !transcribeMode}
    <button onclick={() => (transcribeMode = true)} ...>
        {canWrite ? m.transcription_mode_label() : m.transcription_read_label()}
    </button>
{/if}
  • Writers: button on any PDF, label "Transkribieren" (unchanged).
  • Readers: button only when hasTranscription, label "Transkription lesen".

4. Force read mode + hide the Edit tab — +page.svelte

  • Pass canWrite (or a derived canEdit) into TranscriptionPanelHeader.
  • Guard onModeChange so a reader can never be flipped to 'edit' when !canWrite. The {:else} (TranscriptionEditView) branch is then unreachable for readers (panelMode stays 'read'; defaults at :74/:151 already resolve to 'read' when blocks exist).

5. Hide the Edit tab; render a plain label — TranscriptionPanelHeader.svelte (decision: plain label)

Add canEdit: boolean (default true) to Props. When canEdit is true, render the existing Lesen | Bearbeiten segmented toggle. When canEdit is 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. Pass canEdit={canWrite} from the parent.

6. Verify the reader path exposes zero edits

  • TranscriptionReadView — confirm no comment-compose, review toggle, or annotation create/delete.
  • PDF overlay: in read mode annotationsDimmed is set (+page.svelte:257) — confirm the overlay is pointer-events-none (non-interactive), not merely dimmed, so a reader can't click-drag a new annotation.
  • Comment thread: 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):

transcription_read_label → "Transkription lesen" / "Read transcription" / "Leer transcripción"

No backend model/OpenAPI change → no npm run generate:api.


Tests

Extend existing files — do not create new ones:

  • TranscriptionPanelHeader.svelte.test.ts (already covers hasBlocks): canEdit=true → both tabs present; canEdit=falsemode-edit absent, 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. With hasTranscription as a prop these are deterministic (no async store to stub).
  • Backend permission-boundary (regression suite): READ_ALL principal → GET /api/documents/{id}/transcription-blocks returns 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.
  • One browser/E2E happy path (CI only): READ_ALL opens the read view on a document with a transcription → read text visible, mode-edit absent, no per-block edit/delete/review controls, panel cannot switch to edit.

Run individual specs locally; leave Playwright to CI per project convention.


Acceptance criteria

Scenario: Read-only user reads an existing transcription
  Given I have only READ_ALL permission
  And the document has at least one transcription block
  When I open the document detail page
  Then I see a "Transkription lesen" control
  And opening it shows the read view with the transcription text
  And the panel header shows a plain "Transkription" label, not a Lesen/Bearbeiten toggle
  And there are no per-block edit, delete, or review controls

Scenario: Read-only user, no transcription
  Given I have only READ_ALL permission
  And the document has no transcription blocks
  Then no transcription/read control is shown

Scenario: Writer is unchanged
  Given I have WRITE_ALL permission
  Then I see the "Transkribieren" button, both Lesen and Bearbeiten tabs, and all editing controls

Scenario: Entry signal is correct on first paint
  Given a read-only user loads a document that has a transcription
  Then the "Transkription lesen" control is present on first render (no late pop-in / layout shift)
  • Mobile-first: verify at 320px that the read view + PDF fit with the panel open (it stacks below the PDF on mobile, :288). Entry control ≥44px touch target.
  • No regression for WRITE_ALL / ADMIN users.

Resolved decisions (from multi-persona review)

  1. "Has a transcription to read" = any block exists (blockCount > 0), no reviewed requirement.
  2. Single read tab → plain label ("Transkription"), not a lone pill.
  3. Reader entry label → "Transkription lesen".
  4. hasTranscription sourced from the server load as a cheap exists/count (Markus: SSR correctness; Leonie: no layout shift; Tobias: cheap query) — not the lazy client store.
  • Companion: #696 fix(ui): hide write/edit controls from READ_ALL users — the simple gates. This is the complex half of the same goal.
## Context The transcription panel on the document detail page (`frontend/src/routes/documents/[id]/+page.svelte`) is today entirely gated behind `canWrite`. A `READ_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: - **READ_ALL user, document has a transcription** → can open the panel, sees **only** the read view; no edit tab, no per-block edit/delete/review controls, no "Transkribieren" affordance. - **READ_ALL user, no transcription** → the entry control is hidden entirely (nothing to read). - **WRITE_ALL user** → unchanged: read + edit tabs, full controls. The architecture already supports the rendering: a read-only path (`TranscriptionReadView`), a `panelMode: 'read' | 'edit'` switch, and a `hasBlocks` flag 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`. > **Review outcome (decisions resolved — see end of issue):** "has a transcription to read" = **any block exists** (`blockCount > 0`); the single read tab renders as a **plain label**, not a lone pill; the reader entry label is **"Transkription lesen"**. The `hasTranscription` signal is **sourced from the server load**, not the lazy client store. --- ## Current code ### Entry control — `frontend/src/lib/document/DocumentTopBarActions.svelte:25` ```svelte {#if canWrite && isPdf && !transcribeMode} <button onclick={() => (transcribeMode = true)} ...>{m.transcription_mode_label()}</button> {/if} ``` → Readers never get a button. The component receives `canWrite`, `isPdf`, `transcribeMode` but no "does a transcription exist" flag. ### Prop chain (note: **two** render sites) `+page.svelte` → `DocumentTopBar.svelte` (`Props` at `:40`; derives `isPdf` at `:62`) → `DocumentTopBarActions.svelte`. `DocumentTopBar` renders `DocumentTopBarActions` **twice** — desktop (`:131`) and mobile (`:143`). A new prop must reach **both**. ### ⚠️ Why `hasTranscription` must come from the server `transcription.hasBlocks` is a **client store** (`useTranscriptionBlocks.svelte.ts:56`, `blocks.length > 0`) populated by a client-side `fetch('/api/documents/{id}/transcription-blocks')`. `onMount` (`+page.svelte:167`) does **not** call `transcription.load()` — the load fires only from interaction/PDF paths — and `documents/[id]/+page.server.ts` carries **no** transcription data. So gating the entry button on `hasBlocks` means it is `false` at 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.svelte` ```svelte let panelMode = $state<'read' | 'edit'>('read'); // :40 panelMode = transcription.hasBlocks ? 'read' : 'edit'; // :74, :151 defaults {#if transcribeMode} <TranscriptionPanelHeader mode={panelMode} hasBlocks={...} onModeChange={...} /> // :291 ... {:else if panelMode === 'read'} <TranscriptionReadView blocks={...} /> // :338 — read-only, no edit controls ✅ {:else} <TranscriptionEditView canWrite={canWrite} ... /> // :345 — edit controls {/if} {/if} ``` ### Tabs — `frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte` Renders a segmented `Lesen | Bearbeiten` toggle (`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 boolean `hasTranscription` (a `COUNT`/`EXISTS` on 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** (no `reviewed` requirement). Return it in the page data. ### 2. Thread the flag down `+page.svelte` passes `hasTranscription` → `DocumentTopBar` (add to `Props`) → **both** `DocumentTopBarActions` sites (`:131`, `:143`). ### 3. Widen the entry gate — `DocumentTopBarActions.svelte` Add prop `hasTranscription: boolean`. Extract the condition to a `$derived` (no logic in the template): ```svelte const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf); ``` ```svelte {#if canOpenTranscription && !transcribeMode} <button onclick={() => (transcribeMode = true)} ...> {canWrite ? m.transcription_mode_label() : m.transcription_read_label()} </button> {/if} ``` - Writers: button on any PDF, label "Transkribieren" (unchanged). - Readers: button only when `hasTranscription`, label **"Transkription lesen"**. ### 4. Force read mode + hide the Edit tab — `+page.svelte` - Pass `canWrite` (or a derived `canEdit`) into `TranscriptionPanelHeader`. - Guard `onModeChange` so a reader can never be flipped to `'edit'` when `!canWrite`. The `{:else}` (`TranscriptionEditView`) branch is then unreachable for readers (`panelMode` stays `'read'`; defaults at `:74`/`:151` already resolve to `'read'` when blocks exist). ### 5. Hide the Edit tab; render a plain label — `TranscriptionPanelHeader.svelte` (decision: plain label) Add `canEdit: boolean` (default `true`) to `Props`. When `canEdit` is true, render the existing `Lesen | Bearbeiten` segmented toggle. When `canEdit` is **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. Pass `canEdit={canWrite}` from the parent. ### 6. Verify the reader path exposes zero edits - `TranscriptionReadView` — confirm no comment-compose, review toggle, or annotation create/delete. - PDF overlay: in read mode `annotationsDimmed` is set (`+page.svelte:257`) — confirm the overlay is **`pointer-events-none`** (non-interactive), not merely dimmed, so a reader can't click-drag a new annotation. - Comment thread: `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`): ``` transcription_read_label → "Transkription lesen" / "Read transcription" / "Leer transcripción" ``` No backend model/OpenAPI change → no `npm run generate:api`. --- ## Tests Extend existing files — do not create new ones: - **`TranscriptionPanelHeader.svelte.test.ts`** (already covers `hasBlocks`): `canEdit=true` → both tabs present; `canEdit=false` → `mode-edit` absent, 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. With `hasTranscription` as a prop these are deterministic (no async store to stub). - **Backend permission-boundary (regression suite):** READ_ALL principal → `GET /api/documents/{id}/transcription-blocks` returns **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. - **One** browser/E2E happy path (CI only): READ_ALL opens the read view on a document with a transcription → read text visible, `mode-edit` absent, no per-block edit/delete/review controls, panel cannot switch to edit. > Run individual specs locally; leave Playwright to CI per project convention. --- ## Acceptance criteria ```gherkin Scenario: Read-only user reads an existing transcription Given I have only READ_ALL permission And the document has at least one transcription block When I open the document detail page Then I see a "Transkription lesen" control And opening it shows the read view with the transcription text And the panel header shows a plain "Transkription" label, not a Lesen/Bearbeiten toggle And there are no per-block edit, delete, or review controls Scenario: Read-only user, no transcription Given I have only READ_ALL permission And the document has no transcription blocks Then no transcription/read control is shown Scenario: Writer is unchanged Given I have WRITE_ALL permission Then I see the "Transkribieren" button, both Lesen and Bearbeiten tabs, and all editing controls Scenario: Entry signal is correct on first paint Given a read-only user loads a document that has a transcription Then the "Transkription lesen" control is present on first render (no late pop-in / layout shift) ``` - Mobile-first: verify at **320px** that the read view + PDF fit with the panel open (it stacks below the PDF on mobile, `:288`). Entry control ≥44px touch target. - No regression for WRITE_ALL / ADMIN users. --- ## Resolved decisions (from multi-persona review) 1. **"Has a transcription to read" = any block exists** (`blockCount > 0`), no `reviewed` requirement. 2. **Single read tab → plain label** ("Transkription"), not a lone pill. 3. **Reader entry label → "Transkription lesen"**. 4. **`hasTranscription` sourced from the server load** as a cheap `exists`/count (Markus: SSR correctness; Leonie: no layout shift; Tobias: cheap query) — not the lazy client store. ## Related - Companion: #696 `fix(ui): hide write/edit controls from READ_ALL users` — the simple gates. This is the complex half of the same goal.
marcel added the P2-mediumfeatureneeds-discussionui labels 2026-05-31 10:05:04 +02:00
marcel removed the needs-discussion label 2026-05-31 10:56:34 +02:00
Author
Owner

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 cheap existsByDocumentId on the document detail payload — confirmed with the maintainer over the lazy client store).

Commits

  1. feat(transcription): add existsByDocumentId block query — cheap EXISTS in TranscriptionBlockRepository (+ integration test).
  2. feat(transcription): expose hasBlocks on TranscriptionBlockQueryService (+ unit test).
  3. feat(document): add server-computed hasTranscription to detail payload@Transient field populated in DocumentService.getDocumentById (+ unit test).
  4. test(security): lock READ_ALL -> 403 on transcription/annotation writes — explicit READ_ALL boundary on create/update/reorder/review + annotation create/patch.
  5. chore(api): regenerate Document type with hasTranscription.
  6. i18n(transcription): add reader read-label and panel title strings (de/en/es).
  7. 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, threaded doc.hasTranscription.
  8. feat(ui): show read-only transcription header without an edit tabcanEdit on TranscriptionPanelHeader; plain "Transkription" heading + status line.
  9. feat(ui): confine read-only users to the transcription read view — guarded onModeChange, read default for readers, canAnnotate={canWrite} threaded to PdfViewer so the draw/delete/resize overlay is off, OCR status check skipped.
  10. 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 package builds; npm run check clean 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 canDraw gate (now transcribeMode && canAnnotate), which is the actual authorization path and also disables delete/resize — cleaner than a CSS-only pointer-events-none.

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 cheap `existsByDocumentId` on the document detail payload — confirmed with the maintainer over the lazy client store). **Commits** 1. `feat(transcription): add existsByDocumentId block query` — cheap EXISTS in `TranscriptionBlockRepository` (+ integration test). 2. `feat(transcription): expose hasBlocks on TranscriptionBlockQueryService` (+ unit test). 3. `feat(document): add server-computed hasTranscription to detail payload` — `@Transient` field populated in `DocumentService.getDocumentById` (+ unit test). 4. `test(security): lock READ_ALL -> 403 on transcription/annotation writes` — explicit READ_ALL boundary on create/update/reorder/review + annotation create/patch. 5. `chore(api): regenerate Document type with hasTranscription`. 6. `i18n(transcription): add reader read-label and panel title strings` (de/en/es). 7. `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, threaded `doc.hasTranscription`. 8. `feat(ui): show read-only transcription header without an edit tab` — `canEdit` on `TranscriptionPanelHeader`; plain "Transkription" heading + status line. 9. `feat(ui): confine read-only users to the transcription read view` — guarded `onModeChange`, read default for readers, `canAnnotate={canWrite}` threaded to `PdfViewer` so the draw/delete/resize overlay is off, OCR status check skipped. 10. `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 package` builds; `npm run check` clean 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 `canDraw` gate (now `transcribeMode && canAnnotate`), which is the actual authorization path and also disables delete/resize — cleaner than a CSS-only `pointer-events-none`.
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#697