feat(transcription): add "Alle als fertig markieren" bulk action (#345) #352
Reference in New Issue
Block a user
Delete Branch "feat/issue-345-bulk-mark-reviewed"
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?
Summary
Implements the bulk "Alle als fertig markieren" action for the transcription panel requested in #345.
Backend
PUT /api/documents/{documentId}/transcription-blocks/review-allendpoint toTranscriptionBlockController, guarded with@RequirePermission(Permission.WRITE_ALL)markAllBlocksReviewed(UUID documentId, UUID userId)toTranscriptionService—@Transactional, single DB round-trip viablockRepository.saveAll(), emits oneBLOCK_REVIEWEDaudit event per previously-unreviewed blocklistBlocks) for a clean frontend update passTranscriptionServiceTestunit tests (idempotency, audit events, empty document)TranscriptionBlockControllerTest@WebMvcTesttests (401, 403, 200 happy path, 200 empty, 401 user not found)Frontend
onMarkAllReviewed?: () => Promise<void>prop toTranscriptionEditView(optional, consistent withonTriggerOcrpattern)reviewedCount / totalCount geprüfttitle="Alle Blöcke sind bereits als fertig markiert"(Decision 1)markAllReviewed()added todocuments/[id]/+page.svelte, wired asonMarkAllReviewedTranscriptionEditView.svelte.spec.tsVitest Browser component tests; all 25 tests passDecisions applied
titletooltip@TransactionalCloses #345
Adds a single-transaction backend endpoint PUT /api/documents/{id}/transcription-blocks/review-all that marks all blocks as reviewed atomically. Emits N individual BLOCK_REVIEWED audit events (one per previously-unreviewed block). The frontend button is disabled (not hidden) when all blocks are already reviewed, and shows a spinner during the operation. Closes #345 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
What I checked
The pre-implementation review (issue #345) already locked in the structural decisions: single
@Transactionalservice method, no N-sequential-HTTP-calls pattern,blockRepository.saveAll()as the single persistence call, and the audit semantics. The diff follows through on every one of those decisions correctly.Findings
Layer boundaries — correct.
TranscriptionBlockController→TranscriptionService.markAllBlocksReviewed()→blockRepository. No boundary violations. The controller stays thin: extract userId, delegate, return result. The service owns all logic.@Transactionalplacement — correct.markAllBlocksReviewedis annotated@Transactional. Read methods in this class are not. Consistent with the existing pattern (reviewBlock,reorderBlocks, etc.).Endpoint path — correct.
PUT /review-allon the existingTranscriptionBlockControllerbase path. Consistent with the/reorderprecedent for document-level batch operations.Audit semantics — Decision 2 applied correctly.
The loop calls
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, ...)once per previously-unreviewed block, skipping already-reviewed ones. This matches the chosen option (N individualBLOCK_REVIEWEDevents). ThesaveAll(blocks)call persists all blocks (including already-reviewed ones) in one round-trip — this is fine and correct.Suggestion (non-blocking)
The
saveAll(blocks)persists all blocks even when only a subset changed. For a family archive with ≤ 50 blocks this is completely acceptable. If this were a high-volume system I'd suggest building aList<TranscriptionBlock> modifiedand callingsaveAll(modified). At this scale: don't bother.Unrelated change in scope
+page.svelteincludeshandleAnnotationDeleteRequest/onDeleteAnnotationRequestchanges that are not related to the bulk-review feature. This is not a blocker but it's worth noting — ideally unrelated changes ship in their own commit or PR for a clean blame history. Not requesting changes for this.👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Backend
The service method is clean and readable. Guard clause pattern used correctly:
if (!block.isReviewed())before mutating. Functions are short. Naming reveals intent throughout.One minor note:
saveAll(blocks)saves all blocks, including already-reviewed ones. This works correctly and is consistent with Hibernate's dirty-tracking (no-ops for unchanged entities in a@Transactionalcontext). No change needed.Frontend
$derivedusage — correct.allReviewedis a$derived, not a$state+$effect.showMarkAllButtonfrom the issue spec is not needed since the button visibility is controlled by the prop's presence ({#if onMarkAllReviewed}) and the disabled state is controlled byallReviewed. Clean.markingAllReviewedas$state— correct.Loading state is mutable, not derived — this is the right choice.
handleMarkAllReviewed— correct pattern.try/finallyguarantees the spinner clears even on error. No early return that could leavemarkingAllReviewed = trueforever.State update after
markAllReviewed()in+page.svelte:This mutates elements in the
transcriptionBlocksarray in place. In Svelte 5, array element mutation is reactive if the array is$state. Verified this is consistent with the existingreviewTogglepattern on line 134 of+page.svelte— same approach. LGTM.Unkeyed
{#each}check: The existing block list uses keyed{#each blocks as block (block.id)}— confirmed in prior work on this file. The new code doesn't add any{#each}without a key.One concern (non-blocking): The
markAllReviewedhandler in+page.sveltehas no error feedback:Silent failure on the bulk action is worse UX than on the single-block toggle (same pattern). The requirements engineer flagged this in the issue. Since Decision 3 makes this all-or-nothing at the DB layer, a failure means nothing changed — the silent return is not data-corrupting, just unhelpful. Worth a follow-up issue.
Unrelated change:
handleAnnotationDeleteRequestin+page.svelteis not related to this feature. Should have been in a separate commit. Not a blocker.🔒 Nora "NullX" Steiner — Security Engineer
Verdict: ✅ Approved
Security review
Authentication guard — correct.
Authentication authenticationparameter withrequireUserId(authentication)follows the identical pattern tocreateBlockandreviewBlock. Ifauthenticationis null or the user cannot be resolved,requireUserIdthrows — verified by the 401 testmarkAllBlocksReviewed_returns401_whenUserNotFoundInDatabase.Authorization guard — correct.
@RequirePermission(Permission.WRITE_ALL)is present on the new endpoint. No request body to validate, so the attack surface is limited to thedocumentIdpath variable, which Spring auto-validates as aUUIDat binding time — any non-UUID input returns 400 before the method is entered.IDOR protection — correct.
blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId)scopes the fetch to the requested document ID. All blocks returned belong to that document. NofindAll()+ Java-layer filter is used. The existinggetBlock(documentId, blockId)IDOR guard is not needed here since we're fetching by document scope, not by block ID.Permission boundary tests — present and correct.
markAllBlocksReviewed_returns401_whenUnauthenticated— covers missing authenticationmarkAllBlocksReviewed_returns403_whenMissingWriteAllPermission— coversREAD_ALLonlymarkAllBlocksReviewed_returns401_whenUserNotFoundInDatabase— covers resolved user = nullThis is the full set required. The pattern matches what the pre-implementation review specified.
No new attack surface.
No request body, no file upload, no redirect, no user-controlled query parameters beyond the UUID path variable. The security footprint of this endpoint is minimal.
One observation (non-blocking)
The
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null)passesnullas the fourth parameter (blockId). The existingreviewBlockalso passesnullfor blockId in the audit event — so this is consistent. If the audit log is ever surfaced to end users, the null blockId on bulk events will make it harder to identify which blocks were reviewed. Not a security concern — a UX/observability note for a future audit-log improvement issue.🧪 Sara Holt — QA Engineer
Verdict: ⚠️ Approved with concerns
What's well-covered
The test suite covers the scenarios I'd expect at each layer:
Service unit tests (
TranscriptionServiceTest) — 5 tests:Controller slice tests (
TranscriptionBlockControllerTest) — 5 tests:Component tests (
TranscriptionEditView.svelte.spec.ts) — 5 tests:Test names follow the
should_/given-when-thensentence style throughout. Factory constants (unreviewedBlock1,reviewedBlock2) keep setup to one line per test. One logical assertion per test. All correct.Concerns
Missing test: mixed state (some reviewed, some not).
The service tests test the all-unreviewed and all-reviewed cases, but not the mixed case:
block1.reviewed = true,block2.reviewed = false. The idempotency test covers "all already reviewed" and the main test covers "all unreviewed." The mixed case is where theif (!block.isReviewed())branch condition matters most — it exercises both thetrueandfalsepaths in a single call. The audit-event count test (markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks) does cover mixed state for the audit path, but the reviewed-flag assertion after the call is missing from that test. Suggest adding:Non-blocking, but it closes a gap.
Missing component test: button disabled when
allReviewedis true via$derived.The existing test
disables button when all blocks are already reviewedrenders with all blocks pre-reviewed. There's no test for the transition: start with unreviewed blocks, click the button, verify button becomes disabled after the callback resolves and the parent updates block state. This is harder to test without actual state propagation — acceptable to defer to a Playwright E2E scenario.No E2E test exists yet.
The issue specified one Playwright E2E scenario: open document → click button → verify reviewed state → un-mark one block → verify revert. This PR ships the feature without an E2E test. For Demo Day milestone, this is an acceptable gap if there's a follow-up issue. Recommend creating one.
Unrelated test in the diff.
deleteBlock_returns403_whenUserHasOnlyReadAllPermissionwas added inTranscriptionBlockControllerTest— this is for the delete endpoint, not review-all. It's a valid test (the gap was real) but it's an unrelated change. Not a blocker.🎨 Leonie Voss — UI/UX Designer
Verdict: ✅ Approved
What I checked
Placement — correct.
Button is in the sticky progress header
<div>, right-aligned next toreviewedCount / totalCount geprüftvia aflex items-center justify-betweenrow. This is exactly where the user's eye is while reviewing — the context justifies the action. LGTM.Touch target — correct.
min-h-[44px]is present on the button. This meets WCAG 2.2 minimum (44px) for the 60+ transcriber audience. Well done.Focus indicator — correct.
focus-visible:ring-2 focus-visible:ring-brand-navyis applied. Keyboard navigators will see the ring.focus-visible(notfocus) avoids showing the ring on mouse click. Matches the project's pattern.Disabled state — correct.
Decision 1 was to use
disabled(not hidden). Implementation uses:disabledkeeps the button visible withopacity-40. Thetitletooltip explains why. The feature remains discoverable. LGTM.Loading state — correct.
Spinner SVG replaces the checkmark icon during
markingAllReviewed. No external indicator — the change is in-button. Clean and unobtrusive.Button text color — verify needed.
text-brand-navy/60at 60% opacity againstbg-surface(#E4E2D7):brand-navyis#002850. At 60% opacity on#E4E2D7, the effective color is approximately#668099. Against#E4E2D7that gives roughly 3.2:1 contrast — this fails WCAG AA for normal text (4.5:1 required). The pre-implementation Leonie review in the issue called this out and recommendedtext-brand-navy/60as passing, but I'm flagging it for an actual contrast check with a tool.aria-hidden="true"on SVG icons — correct.Both SVGs have
aria-hidden="true". The button textAlle als fertig markierenprovides the accessible name. Screen readers announce the button correctly without reading SVG paths. LGTM.No
aria-labelon the button when disabled.When
allReviewed = true, the button is disabled and has atitleattribute.titleis announced by some but not all screen readers. Consider addingaria-labeloraria-describedbyfor better screen reader support. Non-blocking suggestion —titleis the accepted accessible pattern for tooltip-style explanations.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
AC traceability
@Transactionalservice calldisabledwithtitletooltip (Decision 1 applied)onReviewToggleunchanged; individual blocks still mutableDecision traceability
All four decisions from the Decision Queue (issue comment #4881) are correctly applied in the implementation. The implementation comment (issue comment #4894) accurately describes what was built. No gap between decision and code.
Residual requirement gap (non-blocking, follow-up issue recommended)
Failure path still unaddressed.
The Requirements review in the issue flagged: "Given the user clicks the button and the operation fails, then an error message is shown and the block states are unchanged."
The implementation does:
Silent return on failure — no error message shown, no visual feedback to the user. The
@Transactionalguarantee means block states are indeed unchanged in the database (all-or-nothing, Decision 3). But the frontend shows no indication that the operation failed. For a single-block toggle, this is the existing behavior (reviewTogglealso silently returns). For a bulk action, silent failure is more disorienting — the user clicked a document-wide action and received no confirmation.This was marked "nice to have" in the issue and is not a blocker for the Demo Day milestone. Recommend a follow-up issue: "Show error toast when bulk-review-all fails."
Scope note
The PR includes
handleAnnotationDeleteRequestin+page.svelte, which is outside the scope of issue #345. The change itself appears correct, but it was not spec'd in #345. No AC exists for it. Recommend creating a separate issue to track this change if it is not already tracked elsewhere.🚀 Tobias Wendt — DevOps & Platform
Verdict: ✅ Approved
Infrastructure review
Zero new infrastructure. No schema migration, no Docker Compose change, no new service, no new environment variable. Operational footprint: none.
Query performance.
findByDocumentIdOrderBySortOrderAsc(documentId)is the same repository method already used bylistBlocks. It runs against an indexed column. For ≤ 50 blocks the query +saveAllwill complete well under 50ms. No caching, no async boundary, no pre-emptive optimization warranted.saveAllbehaviour. Hibernate batches the updates within the@Transactionalboundary. Spring Data JPA'ssaveAllissues an UPDATE per dirty entity (Hibernate dirty-tracking). For 50 blocks this is 50 UPDATE statements in one transaction — well within acceptable range. No batch-size configuration needed at this scale.Audit log volume. N
BLOCK_REVIEWEDevents per bulk action. At this project's scale (family archive, occasional use), this is not a concern. If bulk actions are used repeatedly on large documents, the audit table grows proportionally — same rate as N individual reviews would. No operational concern.API test file. The pre-implementation DevOps review (issue #4880) recommended adding the new endpoint to
backend/api_tests/. This PR does not include that addition. Non-blocking, but worth a quick follow-up — it costs 3 lines and makes manual testing easier without spinning up the frontend.All 68 backend tests pass per the implementation comment. Nothing in this PR touches CI configuration or Compose files. Ready to merge.
🗳️ Decision Queue — PR #352
Consolidated open items from the persona reviews that need a decision or follow-up action before or after merge.
Item 1: Button text contrast ratio (Leonie — blocker candidate)
Raised by: Leonie (UX)
text-brand-navy/60onbg-surface— Leonie's calculation suggests this may be ~3.2:1, which fails WCAG AA for 12px (text-xs) text (4.5:1 required). The pre-implementation spec recommendedtext-brand-navy/60as acceptable but did not include a verified contrast ratio.Action needed: Run
brand-navy #002850at 60% opacity on#E4E2D7through WebAIM Contrast Checker. If it fails 4.5:1, update the button totext-brand-navy/80ortext-brand-navy.Blocking?: Yes, if the contrast fails. Can be verified in 2 minutes.
Item 2: Unrelated
handleAnnotationDeleteRequestchanges in+page.svelte(Markus, Felix, Elicit)Raised by: Markus (Architect), Felix (Developer), Elicit (Requirements)
The PR includes
handleAnnotationDeleteRequestandonDeleteAnnotationRequestwiring in+page.svelte. This is not related to #345 and has no linked issue or AC.Action needed: Decide whether to (a) accept it as-is in this PR with a note in the commit message, or (b) move it to a separate issue/commit. If accepted as-is, ensure there is a Gitea issue tracking the annotation delete confirmation flow.
Blocking?: No — but the change is currently untracked if no issue exists for it.
Item 3: Missing E2E test (Sara — follow-up issue recommended)
Raised by: Sara (QA)
The feature ships without a Playwright E2E test covering: open document → click "Alle als fertig markieren" → verify reviewed state → un-mark one block → verify revert.
Action needed: Create a follow-up Gitea issue for the E2E scenario before the Demo Day milestone closes.
Blocking?: No — the unit and component tests are sufficient for this milestone.
Item 4: Silent failure on bulk-review error (Felix, Elicit — follow-up issue recommended)
Raised by: Felix (Developer), Elicit (Requirements)
markAllReviewed()in+page.sveltesilently returns on!res.ok. No error toast, no user feedback.Action needed: Create a follow-up Gitea issue: "Show error toast when bulk-review-all fails" — link it to the existing error-toast infrastructure if one exists.
Blocking?: No — same pattern as
reviewToggle. Not data-corrupting (all-or-nothing transaction). Demo Day acceptable.Item 5: Missing API test file entry (Tobias — minor)
Raised by: Tobias (DevOps)
backend/api_tests/does not have an entry forPUT /review-all. Three-line addition.Action needed: Add the HTTP test entry in a follow-up commit or alongside any future api_tests update.
Blocking?: No.
🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
What I checked (post-merge commit)
The merge commit (
c641d704) resolves the+page.svelteconflict cleanly, taking theres.okcheck frommain— the correct choice. The two additional changes (contrast bump andTranscription.http) don't introduce any structural concerns.Layer boundaries — still correct
TranscriptionBlockController→TranscriptionService.markAllBlocksReviewed()→blockRepository. No violations. The controller stays thin: extractuserId, delegate, return result.Conflict resolution — correct
The merge took
origin/main's version of the orphaned annotation delete:This is strictly better than the PR's original silent
await— it surfaces errors instead of swallowing them. Correct resolution.API test file — correct addition
Transcription.httpfollows the existing convention (plain### comment, full URL with{{documentId}}placeholder,Authorization: Basic admin admin123). No concerns.Tracking issue for unrelated change
The
handleAnnotationDeleteRequestwiring is now tracked in issue #357. That's the right disposition — accept in this PR, track separately. Clean.👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Contrast fix — correct
text-brand-navy/60→text-brand-navy/80. The calculated contrast at 80% opacity on#E4E2D7is 6.75:1, comfortably above the 4.5:1 threshold fortext-xs(12px). The blocker is resolved.Conflict resolution — correct call
Taking
main'sres.okguard on the orphaned annotation delete is the right choice:This is an improvement over the PR's original
await fetch(...)with no error handling. The error propagates up tohandleAnnotationDeleteRequest, which should surface it appropriately. LGTM.markAllReviewedstate update — correctMutates in place on a
$statearray — consistent withreviewTogglepattern. Svelte 5 reactive.Silent failure on
!res.ok(follow-up tracked)Tracked in #356. Not blocking this merge.
Transcription.http— minimal but correctThree lines, follows the existing format. Good enough for the DevOps request.
🔒 Nora "NullX" Steiner — Security Engineer
Verdict: ✅ Approved
Security review of the new commit
The merge commit introduces no new security surface. The contrast fix is purely CSS. The
Transcription.httptest file usesadmin admin123— the same hardcoded dev credentials already present inAdmin-Auth.httpandUser.http. No production secrets committed.Conflict resolution security check
The resolved conflict takes
main's version:From a security standpoint, throwing on
!res.okis correct — it prevents silent undefined behavior on 401/403 responses. No concern.Original security posture unchanged
@RequirePermission(Permission.WRITE_ALL)on the new endpoint — correctrequireUserId(authentication)guard — correctdocumentIdscoped repository fetch — no IDOR riskNo new security concerns introduced by this commit. Ready to merge.
🧪 Sara Holt — QA Engineer
Verdict: ✅ Approved
Test coverage update
No new tests were added in the merge commit — the existing 25 passing tests (5 service unit, 5 controller slice, 5 component, plus the existing suite) cover the feature. The merge commit is infrastructure (conflict resolution + contrast fix + API test file).
Contrast fix — verifiable
text-brand-navy/80on#E4E2D7:#2d4d6bThe blocker is resolved with verified numbers.
Transcription.http— not a test gap fillerThe HTTP test file is a manual dev helper, not automated. The gap I flagged (missing Playwright E2E) is tracked in issue #355. No change needed here.
Remaining gap (non-blocking, tracked)
Mixed-state assertion in
markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks— addingassertThat(result.get(1).isReviewed()).isTrue()would close it. Still a non-blocking suggestion; tracked in #355 alongside the E2E work.Overall
All flagged concerns from the Decision Queue have correct dispositions: one fixed in code (contrast), one added in code (API test), three tracked as follow-up issues (#355, #356, #357). LGTM.
🎨 Leonie Voss — UI/UX Designer
Verdict: ✅ Approved
Contrast fix — confirmed ✅
The blocker I raised is resolved.
text-brand-navy/80onbg-surface (#E4E2D7):#5b7286#2d4d6bThe
text-xs(12px) button text is now fully WCAG AA compliant. The visual change is subtle — 80% opacity still reads as a muted secondary action, not as a primary CTA — which is the right hierarchy for this button next to the progress counter.Hover behaviour — unaffected
hover:text-brand-navystill transitions to full opacity on hover. The resting state is now 80% (was 60%) and the hover state is still 100%. The interaction feel is preserved.Everything else — unchanged from prior approval
Touch target (44px min-h), focus ring (
focus-visible:ring-2), disabled state (opacity-40+title), spinner,aria-hiddenon SVGs — all correct. No regressions introduced by the merge commit.The blocker is closed. Ready to merge.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Decision Queue disposition — complete
All 5 items from the Decision Queue have been resolved:
text-brand-navy/80(6.75:1 ≥ 4.5:1)handleAnnotationDeleteRequestchangesbackend/api_tests/Transcription.httpAC traceability — unchanged
All five ACs from issue #345 remain satisfied. The merge commit does not touch any feature behavior — only styling, infrastructure, and conflict resolution.
Merge conflict resolution — correct
The resolved conflict takes
main's version of the annotation delete. This is a correct, non-regressive resolution. The feature's behavior is unaffected.Residual items (follow-up issues open)
#355, #356, #357 are open and correctly scoped. Nothing blocks this PR from merging.
🚀 Tobias Wendt — DevOps & Platform
Verdict: ✅ Approved
API test file — added ✅
backend/api_tests/Transcription.httpis now present with thePUT /review-allentry:Follows the existing file conventions exactly. The minor item I flagged in the original review is resolved.
Merge commit — no operational impact
The merge pulls in many changes from
main(frontend components, test files, CSS) — none of these touch infrastructure, Compose, or migrations. Zero operational delta from the merge.Conflict resolution — no operational concern
The resolved conflict in
+page.svelteis purely frontend JavaScript. No impact on deployment, build pipeline, or runtime configuration.Build check
All 68 backend tests were passing before the merge commit. The merge commit does not touch any Java source. The frontend changes (contrast class, no logic) will not break the build. No CI concern.
Ready to merge
mergeable: trueis confirmed by Gitea. No blockers remain. This PR is ready.PR #352 — Final Summary
Overall verdict: ✅ All personas approved — ready to merge
What was done since the Decision Queue (commit
c641d704)text-brand-navy/60(3.83:1, ❌ WCAG AA fail) →text-brand-navy/80(6.75:1, ✅ WCAG AA pass) inTranscriptionEditView.sveltehandleAnnotationDeleteRequestbackend/api_tests/Transcription.httpwithPUT /review-allentryMerge conflict resolution
Conflict in
frontend/src/routes/documents/[id]/+page.svelteresolved by takingorigin/main's version — addsres.okerror check to the orphaned annotation delete path (strictly better than the PR's original silentawait).State after this commit
mergeable: true(confirmed by Gitea after push)