feat(transcription): add "Alle als fertig markieren" bulk action to transcription panel #345
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?
User story
As a transcriber, I want a "Alle als fertig markieren" button in the transcription panel, so that I can approve all blocks after a review in one click instead of clicking each one individually.
Context
After reviewing a full document, users must click "Als fertig markieren" on every block one by one. For documents with many blocks this is tedious repetitive clicking — a bulk action eliminates the friction.
Acceptance criteria
NFRs
🏗️ Markus Keller — Application Architect
Observations
PUT /api/documents/{documentId}/transcription-blocks/{blockId}/reviewcorrectly toggles a single block. There is no bulk variant yet.reviewBlockservice method is transactional and atomic per block — a bulk endpoint needs its own@Transactionalboundary covering all N updates in a single transaction, or a deliberately separate approach with partial-success semantics. The issue is silent on which is required.reviewedalready exists ontranscription_blocks. This is purely a service/controller addition.Recommendations
PUT /api/documents/{documentId}/transcription-blocks/review-all(no request body needed). TheTranscriptionBlockControlleralready has the/reorderprecedent for document-level batch operations on the same base path.markAllBlocksReviewed(UUID documentId, UUID userId)inTranscriptionServicewith@Transactional— one transaction, oneblockRepository.saveAll(blocks)after settingreviewed = trueon each. This keeps the atomicity guarantee at the database layer where it belongs.markAllBlocksReviewedvariant that callsreviewBlock()in a loop — that creates N separate transactions and breaks the audit log semantics (each existingBLOCK_REVIEWEDaudit event logs one block; a bulk action should emit a singleALL_BLOCKS_REVIEWEDaudit kind or emit nothing extra).reviewBlockemitsAuditKind.BLOCK_REVIEWEDper block. Calling it N times from a bulk action would flood the audit log. Either skip individual audit events in the bulk path and emit a newAuditKind.ALL_BLOCKS_REVIEWED, or accept no audit event for the bulk action (simpler, acceptable given this is a convenience shortcut, not a workflow gate).Open Decisions
ALL_BLOCKS_REVIEWEDaudit event, emit N individualBLOCK_REVIEWEDevents, or emit nothing? The existingreviewBlockaudit event is already per-block, so a bulk action creates 10× the noise if it reuses the same path.👨💻 Felix Brandt — Fullstack Developer
Observations
reviewTogglefunction in+page.svelte(line 108–115) makes onePUTper block. The bulk action must NOT be implemented by looping overtranscriptionBlocksand calling this function N times — that produces N round-trips, N optimistic UI updates in sequence, and a half-marked state visible to the user if any call fails.TranscriptionEditView.sveltealready hasreviewedCount,totalCount, andreviewProgressas$derivedvalues — the sticky progress header updates automatically when the underlyingblocksarray changes. The bulk action just needs to flipreviewed = trueon all items in the sharedtranscriptionBlocksstate after the single HTTP call returns.$derived, not inline in the template:> 10blocks (NFR) is a$stateflag, not a derived. Pattern:TranscriptionEditView.svelte, inside the existing sticky progress header<div>, adjacent to thereviewedCount / totalCounttext. That's where the user's eye is already looking.onMarkAllReviewedlive? It's a document-level side-effect (updatestranscriptionBlocksin+page.svelte). Options: (a) pass it down as a prop from page → EditView, consistent withonReviewToggle; (b) call it directly inEditViewusing a fetch if blocks are already client-side state there. The existing pattern passes callbacks down — stay consistent.Recommendations
onMarkAllReviewed: () => Promise<void>toTranscriptionEditView'sPropstype, implement the handler in+page.svelte, and wire it through — mirrors the existingonReviewTogglepattern exactly.listBlocks), so the frontend can do a single optimistic update pass instead of making assumptions about which blocks changed.fetch, assert it is called once withPUT /api/documents/.../review-all, then assert all blocks in the reactive state havereviewed = trueafter resolution.🔒 Nora "NullX" Steiner — Security Engineer
Observations
PUT /{blockId}/reviewis correctly guarded with@RequirePermission(Permission.WRITE_ALL). The new bulk endpoint must carry the same annotation — do not forget it because there is no request body to validate.reviewBlockservice method acceptsdocumentId+blockIdand callsgetBlock(documentId, blockId)which usesfindByIdAndDocumentId— this is the correct IDOR guard. The bulk variantmarkAllBlocksReviewed(documentId, userId)must operate only on blocks belonging todocumentId. UsingblockRepository.findByDocumentIdOrderBySortOrderAsc(documentId)(the existing query) scopes the fetch to the authenticated document — this is correct. Do not useblockRepository.findAll()+ filter in Java.documentIdpath variable is already a UUID, which Spring auto-validates at binding time.Authentication authentication→requireUserId(authentication)) must still be present so theupdatedByfield and the audit log receive the correct user ID — same ascreateBlockandreviewBlock.Recommendations
@WebMvcTestsecurity test verifying thatPUT /review-allreturns 401 when unauthenticated and 403 when authenticated withREAD_ALLonly — following the same pattern as the existingTranscriptionBlockControllerTestfor/{blockId}/review. This feature has no meaningful attack surface beyond the existing permission model, but the tests prove the guard is not accidentally missing.🧪 Sara Holt — QA Engineer
Observations
> 10 blockstrigger is client-side branching in the UI; it needs a Vitest component test with a controlled block list size.reviewTogglehas zero unit test coverage in the Vitest suite (only E2E). The bulk action should not repeat this gap.Recommendations
markAllBlocksReviewedservice method in isolation withMockitoExtension. Two cases: (1) blocks that are already reviewed stay reviewed (idempotency); (2) unreviewed blocks become reviewed. VerifyblockRepository.saveAll()is called once with the correct collection.@WebMvcTest): Four tests — 200 happy path withWRITE_ALL, 401 unauthenticated, 403 withREAD_ALLonly, 200 with empty block list (zero blocks → should return empty list without error).TranscriptionEditViewwith a controlled block list. Assert button is visible when 2+ unreviewed blocks exist, hidden when all are reviewed. Mock theonMarkAllReviewedcallback and assert it is called exactly once on button click.🎨 Leonie Voss — UI/UX Designer
Observations
TranscriptionEditView.sveltesticky progress header already showsreviewedCount / totalCount geprüftand a mint progress bar. This is the natural home for the bulk action button — it is visually adjacent to the context that justifies the action (the review count), and sticky positioning means it remains accessible while scrolling through long block lists.disabled+title="Alle Blöcke sind bereits als fertig markiert".> 10 blocksas the trigger. Visually, showing a spinner inside or beside the button is the right affordance — replacing button text with a spinner is cleaner than an external indicator.Recommendations
<div>, right-aligned, on the same line as the progress text:focus-visible:ring-2 focus-visible:ring-brand-navyfor the focus indicator — consistent with the project's keyboard navigation pattern.brand-minttext on abg-surfacebackground for the button label — that combination fails WCAG AA at normal text size. Usetext-brand-navy/60(at 60% opacity it still clears 4.5:1 againstbg-surface).Open Decisions
📋 Elicit — Requirements Engineer
Observations
Gap 1 — "Alle Blöcke" scope is undefined. The AC says "all blocks in the current document are marked as ready in one action." Does "current document" mean: (a) all blocks regardless of page, or (b) only blocks visible in the current viewport/panel state? For a multi-page document with 30 blocks across 4 pages, is the expectation that all 30 are marked at once? This needs to be explicit because the backend
listBlocks(documentId)returns all blocks for the document, but the user may expect only visible blocks to be affected if the UI shows pagination.Gap 2 — "Hidden or disabled" is a decision, not a requirement. The AC presents two options ("hidden or disabled") as equivalent. They are not: hidden means the feature disappears from the UI, disabled means it is visible but inactive. These produce different user experiences and different accessibility semantics. The requirement should specify one.
Gap 3 — Loading indicator trigger ("for documents with > 10 blocks") creates a conditional UX. Users with 8 blocks get no loading feedback; users with 12 get a spinner. The threshold is arbitrary and invisible to users. A simpler requirement: always show a loading indicator during the operation. The NFR should be "show a loading indicator during the operation; the interaction must complete within 3 seconds for documents with ≤ 50 blocks."
reviewBlocksingle-block path silently returns without updating the UI on failure (if (!res.ok) return). A bulk action failing silently is a worse user experience.Recommendations
aria-label="Alle Blöcke sind bereits als fertig markiert"when all blocks are already reviewed.Open Decisions
🚀 Tobias Wendt — DevOps & Platform
Observations
findByDocumentIdOrderBySortOrderAsc(documentId)runs against an indexed column (document_id). For the expected document scale (≤ 50 blocks), the query +saveAllwill complete well under 100ms. No caching, no async, no extra infrastructure warranted.> 10blocks. At the current data scale, the operation is fast enough that a loading indicator is a UX affordance, not a performance mitigation. This does not require any backend changes.Recommendations
markAllBlocksReviewedendpoint becomes a hotspot in future (unlikely at family archive scale), the existing Prometheus/Actuator metrics will surface it before any manual intervention is needed. No pre-emptive optimization.PUT /review-allendpoint should appear in the existingbackend/api_tests/HTTP test file fortranscription-blocksso it can be exercised manually during development without spinning up the frontend.🗳️ Decision Queue
Consolidated open decisions from the persona reviews. Each needs a human call before implementation starts.
Theme 1: Button state when all blocks are reviewed
Raised by: Leonie (UX), Sara (QA), Elicit (Requirements)
The AC says "hidden or disabled" — but these are meaningfully different:
title/aria-labelexplaining why it is inactive. Screen readers can still announce it. The feature remains discoverable.Decision needed: Choose one. Suggested: disabled with
title="Alle Blöcke sind bereits als fertig markiert".Theme 2: Audit log semantics for the bulk action
Raised by: Markus (Architect)
The existing
reviewBlockservice method emitsAuditKind.BLOCK_REVIEWEDonce per block. If the bulk action callsreviewBlockN times internally, it emits N audit events — noisy. If it uses a separate code path (recommended), the options are:AuditKind.ALL_BLOCKS_REVIEWED(one event, carries document ID)BLOCK_REVIEWEDevents (noisy, not recommended)Decision needed: Option A or B. Option B is simpler; Option A gives a cleaner audit trail if the audit log is ever surfaced to users.
Theme 3: Failure atomicity
Raised by: Elicit (Requirements)
If the backend uses a single
@Transactionalmethod for the bulk operation, failure is automatically all-or-nothing. If the frontend implements bulk marking as N sequential calls (explicitly forbidden by Markus and Felix, but worth confirming), partial failure is possible.Decision needed: Confirm the implementation is a single backend transaction (all-or-nothing). This should be the default given the existing
@Transactionalservice pattern — just needs to be explicit so the AC can be updated to state it.Theme 4: Loading indicator trigger threshold
Raised by: Elicit (Requirements)
The NFR currently reads "for documents with > 10 blocks, show a loading indicator." This creates a conditional UX branch that is invisible to users (why does the spinner appear for some documents but not others?).
Decision needed: Always show the loading indicator during the operation (remove the > 10 threshold), or keep the conditional. Always-show is simpler to implement and test.
Implementation complete — branch
feat/issue-345-bulk-mark-reviewedWhat was done
Backend (
PUT /api/documents/{documentId}/transcription-blocks/review-all)markAllBlocksReviewed(UUID documentId, UUID userId)toTranscriptionService—@Transactional, single database round-trip viablockRepository.saveAll(), emits oneBLOCK_REVIEWEDaudit event per previously-unreviewed block (Decision 2: Option C)markAllBlocksReviewedendpoint toTranscriptionBlockControllerwith@RequirePermission(Permission.WRITE_ALL)andAuthentication authentication→requireUserId()pattern matching existing endpointslistBlocks) for a clean frontend update passBackend tests
TranscriptionServiceTest: 5 new unit tests — sets all unreviewed blocks to reviewed, idempotent when already reviewed, emits N audit events for N unreviewed blocks, does not emit audit for already-reviewed blocks, returns empty list for empty documentTranscriptionBlockControllerTest: 5 new@WebMvcTesttests — 401 unauthenticated, 403 withREAD_ALLonly, 200 happy path with block list, 200 with empty list, 401 when user not found in databaseAll 68 backend tests pass (32 service + 36 controller).
Frontend
onMarkAllReviewed?: () => Promise<void>prop toTranscriptionEditView(optional, consistent withonTriggerOcrpattern)reviewedCount / totalCount geprüfttexttitle="Alle Blöcke sind bereits als fertig markiert"(Decision 1)markAllReviewed()handler added todocuments/[id]/+page.svelte, wired through asonMarkAllReviewedFrontend tests (
TranscriptionEditView.svelte.spec.ts)5 new Vitest Browser component tests — button visible when prop provided + unreviewed blocks exist, button absent when prop not provided, button disabled when all reviewed, calls callback exactly once on click, button disabled while in-flight.
All 25
TranscriptionEditViewtests pass.Decisions applied
@Transactional