Compare commits

..

146 Commits

Author SHA1 Message Date
Marcel
c641d704a8 merge: resolve conflict with origin/main + fix WCAG AA contrast + add API test
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m27s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m3s
CI / Unit & Component Tests (pull_request) Failing after 3m16s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
- Merge origin/main (resolved conflict in +page.svelte: use res.ok check from main)
- fix(transcription): bump button text from text-brand-navy/60 (3.83:1) to
  text-brand-navy/80 (6.75:1) to pass WCAG AA 4.5:1 for 12px text
- feat(api-tests): add Transcription.http with PUT /review-all entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:11:58 +02:00
Marcel
5b18b87450 test(security): add 403 permission test for annotation DELETE endpoint
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m4s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m0s
Confirms that DELETE /api/documents/{id}/annotations/{id} requires at
least ANNOTATE_ALL; a user with only READ_ALL receives 403 Forbidden.
Closes the permission audit raised during PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
bfa8b9c147 fix(viewer): move delete button inside annotation bounds to prevent edge clipping
Repositioning from top:-8px/right:-8px to top:4px/right:4px ensures the
44px touch target stays fully within the annotation shape. Annotations drawn
near the top or right edge of the PDF page no longer risk the button being
obscured or inaccessible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
3a94d62c74 test(viewer): verify delete button click does not bubble to onclick
Documents the stopPropagation guarantee: clicking the trash button must
not trigger the annotation's onclick (which opens the block detail panel)
while the delete confirm is in progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
163e99016a fix(viewer): check res.ok on orphaned annotation DELETE to surface errors
Without the guard, a failed DELETE (4xx/5xx) was silently swallowed and
annotationReloadKey was incremented anyway, leaving the annotation visible
and the user with no feedback. Now matches the deleteBlock() pattern
immediately above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
d6f3ca5c43 feat(viewer): show delete icon on annotation for direct block deletion (#339)
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
108edff8d2 feat(persons): show merge panel inline on edit page, remove Gefahrenzone accordion
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Closes #342. The PersonDangerZone collapsible wrapper is removed; PersonMergePanel
is now rendered directly in the edit page with its own red border (border-red-200),
preserving the {#key person.id} state-reset behaviour and the two-step merge flow.

Fix PersonTypeahead mock to use Svelte 5 functional stub (not Svelte 3/4 $$ internals).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:54:45 +02:00
Marcel
3d3fe8d626 fix(pagination): add sr-only span to preserve aria-current on mobile AT
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
When the mobile label is aria-hidden and the desktop button container is
display:none (below sm:), mobile screen reader users had no aria-current
indicator. Added a sr-only span with aria-current="page" that stays in
the AT tree at all breakpoints regardless of CSS display state.

On desktop the active page button also carries aria-current — both
announce the same page information, which is acceptable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
31e5573eab fix(pagination): hide mobile page label from AT tree with aria-hidden
The mobile 'Seite X von Y' span had aria-current='page', which created two
elements announcing the current page on wide screens: the hidden mobile label
and the active desktop button. On sm:+ screens the mobile span is display:none
(removed from AT tree), but on small screens both the span and the desktop
button were redundant.

Replace aria-current with aria-hidden='true' on the mobile label so AT always
relies on the desktop button's aria-current. Updates spec test accordingly and
adds a second assertion in a broader test context (Decision Queue #1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
934a00feb3 fix(pagination): use stable key in {#each} and fix duplicate page number bug
Replaces position-based key `i` with `entry === null ? 'ellipsis-' + i : entry`
so DOM reconciliation is stable when the window shifts (Decision Queue #2).

The index-based key was masking a duplicate-push bug in pageWindow: when
windowStart === first+1 or windowEnd === last-1, the loop already included that
number, causing Svelte to throw `each_key_duplicate` once stable keys are used.
Fixed the bridge-page conditions to use first+2 / last-2 thresholds so the loop
and the bridge branches never push the same page number.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
be27489618 test(pagination): fix test name typo and add totalPages===2 boundary test
Renames 'page button buttons' → 'page buttons container' (Decision Queue #3).
Adds 'renders both pages without ellipsis when totalPages is 2' to cover the
boundary between the 1-page (hidden) and full-ellipsis-window cases (Decision Queue #5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
4e486a31cf feat(pagination): add numbered page-jump buttons to document search
Adds an ellipsis-style numbered page button row (1 … 4 5 6 … 12) to
Pagination.svelte. Buttons are hidden on mobile (sm: breakpoint) and fall
back to the existing prev/next layout. Active page uses brand-navy
background. Client-side clamping via makeHref(entry - 1) satisfies AC3.
i18n key pagination_page_button added for de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
2c5877ea9e fix(a11y): fix ProgressRing text label contrast and add no-restricted-syntax lint rule for text-accent
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
ProgressRing used text-accent (#a1dcd8) on a percentage text label —
same WCAG 2.1 AA failure as #341. Switched to text-primary.

Also adds ESLint no-restricted-syntax rule (scoped to *.svelte files) that
blocks future text-accent usage in JavaScript string literals inside Svelte
class expressions. The rule caught both violations at once; both are now fixed.
The rule is scoped to .svelte files so test assertions against 'text-accent'
strings in .spec.ts files are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:46:44 +02:00
Marcel
cfbe33140c fix(viewer): replace text-accent with text-primary on annotation toggle inactive state
Fixes WCAG 2.1 AA contrast failure (#341): text-accent (#a1dcd8) on light
PDF control bar was 1.52:1 — well below the 4.5:1 AA minimum. text-primary
resolves to #012851 in light mode (14.5:1) and #a1dcd8 in dark mode (9:1) —
both states pass AA in both themes.

Adds PdfControls.svelte.spec.ts with 5 tests covering toggle visibility,
label strings, and the contrast-safe class assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:46:44 +02:00
e8d1835ae1 feat(nav): add tooltip and cursor:pointer to notification bell, fix ThemeToggle i18n (#344) (#351)
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Closes #344

## What was implemented

### Commit 1 — `feat(nav): add cursor-pointer and tooltip to notification bell`
- Extracted `bellLabel` as `$derived` in `NotificationBell.svelte` — eliminates the duplicated inline ternary and keeps tooltip/label in sync reactively
- Added `title={bellLabel}` to the bell `<button>` — native tooltip mirrors `aria-label` in both zero and non-zero unread states
- Added `cursor-pointer` to the bell button's class list
- Added global `button { cursor: pointer; }` rule in `@layer base` of `layout.css` — prevents future regressions (global scope per Decision Queue)
- Added 3 component tests in `NotificationBell.svelte.spec.ts`: cursor-pointer class present, title equals aria-label when unread=0, title equals aria-label when unread=3

### Commit 2 — `fix(nav): replace hardcoded ThemeToggle title with Paraglide i18n keys`
- Added `theme_toggle_to_light` / `theme_toggle_to_dark` keys to `de/en/es` messages
- Extracted `themeLabel` as `$derived` in `ThemeToggle.svelte` and bound both `aria-label` and `title` to it
- Fixes the pre-existing hardcoded English strings (`'light mode'` / `'dark mode'`) per Decision Queue resolution

Touch target size was descoped per the Decision Queue.

## Decision Queue resolutions (from issue #344)
- **cursor-pointer scope**: global via `@layer base` 
- **ThemeToggle scope**: fixed in this issue 
- **Touch target**: descoped 

## Test results
All 5 `NotificationBell` tests pass.

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/351
2026-04-26 21:45:40 +02:00
Marcel
69ac183fe8 feat(transcription): add bulk "Alle als fertig markieren" action to transcription panel
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m31s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m1s
CI / Unit & Component Tests (pull_request) Failing after 3m13s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
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>
2026-04-26 20:53:47 +02:00
Marcel
ce41e96a45 test(audit): add 401 unauthenticated tests for createUser, adminUpdateUser, deleteUser
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m1s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 2m55s
Regression guards verifying that Spring Security returns 401 (not 200) when
no credentials are provided, complementing the existing 403 permission tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:44:03 +02:00
Marcel
a6c8af0971 test(audit): replace null-actorId bootstrap calls with createUserForBootstrap(), increase timeouts to 10s
Removes the wait+clear cycles that existed only to drain the audit events
emitted by createUserOrUpdate(null, ...). Timeouts increased 5 → 10 s to
reduce CI flakiness under load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:41:56 +02:00
Marcel
6d9910b805 refactor(audit): extract createUserForBootstrap() to make null actorId contract explicit
createUserOrUpdate(UUID actorId, ...) is always called from the controller with
a real authenticated actor. createUserForBootstrap() handles seeding/test setup
without emitting an audit event, making the two contracts unambiguous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:39:09 +02:00
Marcel
1dd6e054fc test(audit): add GROUP_MEMBERSHIP_CHANGED integration test with payload assertions
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m59s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m57s
CI / Unit & Component Tests (pull_request) Failing after 3m0s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
23cff1cdd7 refactor(audit): drop @DirtiesContext, add @BeforeEach, use existsByKind in wait conditions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
11d93919b2 refactor(audit): replace LIMIT :limit JPQL with Pageable in audit query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
f6bcc4f72a refactor(audit): extract actorId() helper in UserController
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
f4a4436eda test(audit): add 403 permission tests for createUser, adminUpdateUser, deleteUser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
1d3a3b3338 refactor(audit): extract groupChangePayload() from adminUpdateUser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
77affcfb4f test(audit): integration test — create + delete user produces ordered audit entries
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m4s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m2s
CI / Unit & Component Tests (push) Failing after 3m1s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 3m2s
Creates a real actor user first (needed for audit_log FK constraint),
then creates and deletes a target user, asserts USER_DELETED is newest
and USER_CREATED is second via findRecentUserManagementEvents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
36529f7e11 feat(audit): add findRecentUserManagementEvents query method
Adds findRecentByKinds JPQL query to AuditLogQueryRepository and
findRecentUserManagementEvents(int limit) to AuditLogQueryService,
returning the N most recent USER_CREATED/USER_DELETED/GROUP_MEMBERSHIP_CHANGED
events ordered newest-first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
eb8f9d4dc4 feat(audit): emit GROUP_MEMBERSHIP_CHANGED when admin updates user groups
Adds actorId param to adminUpdateUser(), captures beforeGroups before
mutation, computes added/removed group names, emits logAfterCommit only
when the group set actually changes. Payload contains group names, not
permission strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
a736b7399a feat(audit): emit USER_DELETED when admin removes a user
Adds actorId param to deleteUser(), captures email before deletion,
emits logAfterCommit(USER_DELETED) with userId+email in payload.
Updates UserController to resolve and pass actorId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
e7c7f801c9 feat(audit): emit USER_CREATED when admin creates a new user
Adds USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED to AuditKind.
Injects AuditService into UserService; changes createUserOrUpdate to
accept actorId and emits logAfterCommit(USER_CREATED) only on the
new-user branch. Updates UserController to resolve and pass actorId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
5062513ae6 refactor(persons): extract inputCls/labelCls and PersonFormData type
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m20s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 2m56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
24d5381775 refactor(persons): rename page.server.test.ts to normalizePersonType.test.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
826283afcb test(persons): replace fragile CSS class tests with aria-checked behavior tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
1d5f99a2c8 a11y(persons): add aria-label to PersonTypeSelector radiogroup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
5961bfb916 test(persons): assert error code in createPerson_returns400_whenPersonTypeIsSkip
Adds jsonPath("$.code").value("INVALID_PERSON_TYPE") to verify the full
error response shape, not just the HTTP status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
4c300da65e refactor(persons): remove what-comment from PersonCard title block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
bccff232fe fix(persons): localize validation error messages via Paraglide i18n
validatePersonFields now returns a PersonValidationKey instead of a
hardcoded German string. resolveValidationMessage() translates the key
through Paraglide so English and Spanish locale users no longer see
German error text. Adds validation_last_name_required and
validation_first_name_required to all three message files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
327fd89cb9 refactor(persons): centralise PersonType, PERSON_TYPES and normalizePersonType in person-validation
Removes four independent PersonType type declarations and the duplicated
TYPES/PERSON_TYPES arrays. normalizePersonType moves from the edit route
module into the shared lib so page.server.test.ts no longer imports from a
route. Both server actions now use normalizePersonType for personType
extraction instead of an inline type cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
23861055d1 fix(persons): keyboard navigation now updates PersonTypeSelector reactive state
radioGroupNav now accepts an onChange callback; PersonTypeSelector passes
select() as the callback so ArrowLeft/Right navigation updates the hidden
input value. aria-live region starts empty and announces only on user
interaction (fixes initial page-load announcement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
2ddeb485e3 test(persons): extract validatePersonFields and cover validation branches
- New src/lib/person-validation.ts exports validatePersonFields (pure function)
- 8 unit tests covering: valid PERSON, lastName missing/undefined,
  firstName missing/undefined for PERSON, non-PERSON types without firstName
- Both edit and new-person server actions now call the shared helper instead
  of inline if-chains, making the logic testable and non-duplicated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
1f19fa3462 refactor(persons): export normalizePersonType from edit server module
Tests now import from production code instead of a local copy, giving real
regression protection if the inline logic is changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
7ef1ab3b01 fix(persons): trim title server-side and add SKIP controller test
- PersonController trims title (both create + update) matching the existing firstName/lastName trim pattern
- PersonControllerTest: verifies title is trimmed before service call (ArgumentCaptor)
- PersonControllerTest: verifies createPerson returns 400 when personType is SKIP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
45db75bdf2 fix(persons): use semantic color tokens in PersonTypeSelector for dark mode
Replaces hardcoded brand-navy/brand-sand/white classes with semantic
tokens (bg-primary/text-primary-fg, bg-surface/text-ink, border-line,
ring-focus-ring) so the segmented control adapts correctly in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
8870cbe2fe feat(persons): show title in small-caps above display name in PersonCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
b4cf7f1b21 feat(persons): add type selector + title + conditional fields to new-person form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
d5587d1b95 feat(persons): extract personType + title in edit action; relax firstName for non-PERSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
7699a4e7e2 feat(persons): add type selector + title + conditional fields to edit form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
110416d68b feat(persons): add PersonTypeSelector segmented control component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
64fdc5b57e feat(i18n): add form_label_person_type, form_label_name, a11y_type_changed keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
ac8d0d5796 feat(persons): normalize SKIP→UNKNOWN in edit-route load function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
b8dcb2d3f4 feat(persons): add radioGroupNav action for keyboard navigation in type selector
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
ecd531601a feat(persons): relax firstName requirement for non-PERSON types in controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
fe1101f9d5 feat(persons): updatePerson rejects SKIP with INVALID_PERSON_TYPE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
928ebca056 feat(persons): updatePerson persists personType from DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
5dd4a01995 feat(persons): createPerson(DTO) rejects SKIP with INVALID_PERSON_TYPE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
f4132edc2b feat(persons): add personType to PersonUpdateDTO and wire into createPerson
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
d952fab4cd feat(persons): add INVALID_PERSON_TYPE error code with i18n translations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
d45739cb76 fix(search): use to_tsquery('simple') for prefix transform to avoid German stop word collision
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m51s
CI / OCR Service Tests (push) Successful in 56s
CI / Backend Unit Tests (push) Failing after 3m9s
Words like "Wille" stem to "will" via the German Snowball stemmer, which is
also a German stop word. The prefix-transform step (websearch_to_tsquery text →
regexp_replace → to_tsquery) was passing already-stemmed lexemes back through
the German dictionary, causing them to be silently dropped as stop words. Using
the 'simple' configuration skips stop-word processing entirely while the
tsvector @@ tsquery comparison still works because lexemes are matched by
string value, not by configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:56:55 +02:00
Marcel
18cad798fc fix(documents): preserve archiveBox + archiveFolder in markForReview; drop documentLocation
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m1s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m53s
2026-04-25 20:25:08 +02:00
Marcel
0ddf43947b refactor(documents): drop documentLocation binding from edit layouts; wire archive fields 2026-04-25 20:23:37 +02:00
Marcel
45f7642f8d feat(documents): replace documentLocation with archiveBox/archiveFolder in edit form 2026-04-25 20:11:30 +02:00
Marcel
5a13e61357 feat(documents): wire archiveBox + archiveFolder through DTO and service update 2026-04-25 20:08:21 +02:00
Marcel
a91ee1f26d refactor(documents): unify count + action links into one row
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 2m56s
Move result count, bulk-edit button, and new-document link into a shared
flex row so they appear on the same line. Adds an edit icon to the
bulk-edit button to visually match the existing plus icon on the add link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:41:24 +02:00
Marcel
c59287fcfc fix(bulk-edit): cycle-3 polish — Felix C2/C3/C4/C5 + Sara coverage gaps
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 2m56s
CI / Unit & Component Tests (push) Failing after 3m6s
CI / Backend Unit Tests (push) Failing after 2m56s
CI / OCR Service Tests (push) Successful in 34s
Felix C2 — `BatchMetadataRequest` controller now uses `@Valid` so future
@Size/etc. annotations on the record actually fire.

Felix C3 — Auto-clear `$effect` in `+layout.svelte` reads
`bulkSelectionStore.size` inside `untrack()` so the effect only re-fires on
route change, not on every checkbox toggle.

Felix C4 — `BulkDocumentEditLayout` edit-mode hydration loop now lives
inside `onMount` (not at top-level script) so the SvelteMap mutation is
unambiguously tied to instance lifecycle, matching the pattern used by
`WhoWhenSection`/`DescriptionSection` after the cycle-2 fix.

Felix C5 — Replaced fully-qualified `java.util.LinkedHashSet` in
`DocumentController` with a top-of-file import.

Sara coverage — six new spec files / blocks pin the cycle-1 and cycle-2
behaviours that were previously untested:
 - `WhoWhenSection.svelte.spec.ts` — onMount seeding from initialDateIso /
   initialLocation; doesn't stomp parent-bound dateIso; hideDate / editMode
   branch
 - `DescriptionSection.svelte.spec.ts` — onMount seeding from initialTitle /
   initialDocumentLocation; doesn't stomp parent-bound values; archive-box /
   archive-folder fields visible only in editMode
 - `BulkSelectionBar.svelte.spec.ts` — Esc-scope guard tests for `<dialog>`
   open and `aria-expanded` popover present
 - `BulkDocumentEditLayout.svelte.spec.ts` — topbar reads
   "Massenbearbeitung" + "werden bearbeitet" in edit mode (not the
   upload-flavoured "hochladen"/"werden erstellt" copy)
 - `DocumentControllerTest.patchBulk_returns400_whenArchiveBoxExceeds255Chars`
   — pins the @Size validator on archiveBox via the @Valid wiring

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:18:56 +02:00
Marcel
8ce96294b0 fix(bulk-edit): cycle-2 blockers — restore initial-* props, missing import, scope Esc, edit-mode topbar
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m55s
Felix B1 (data-loss regression on /documents/[id]/edit) — DocumentEditLayout
still passes initialDateIso, initialLocation, initialDocumentLocation, but
my cycle-1 cleanup removed those props. Result: existing values rendered
empty and a save would have overwritten them with "". Restored the props
on WhoWhenSection and DescriptionSection; initialisation now lives in
onMount so it runs exactly once and never stomps a parent-driven update on
a later prop change.

Felix B2 — `DescriptionSection.svelte:36` still had the top-level
`currentTitle = untrack(() => initialTitle)` mutation that I cleaned up in
WhoWhenSection but missed here. Same onMount-once treatment.

Leonie B5 — `enrich/+page.svelte:105` referenced `<BulkSelectionBar>` but
the import was lost in a prettier pass; svelte-check errored out and the
bar never rendered, leaving an 8 rem dead zone from the pb-32 reservation.
One-line fix: add the import.

Leonie B6 — Esc handler in `BulkSelectionBar` was unscoped and stole
Escape from NotificationBell, ConfirmDialog, HelpPopover, etc. (e.g.
selecting docs → opening notification bell → Esc would close the bell
AND silently wipe the selection). Now bails when an open dialog,
expanded menu, or popover is detected.

Elicit C1 — `BulkDocumentEditLayout` topbar now branches on `mode`:
shows "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode
instead of the upload-flavoured "Mehrere Dokumente hochladen" + "werden
erstellt" copy. New i18n keys `bulk_edit_topbar_title` and
`bulk_edit_count_pill` in DE/EN/ES.

Tests added:
 - DocumentControllerTest.patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages
   (Sara C2 follow-up — pin sanitizeForLog as a regression test)
 - BulkSelectionBar.spec — count=1 → "1 Dokument", count=2 → "2 Dokumente"
   (Sara C6 follow-up — pin the new bulk_edit_n_selected_one/_other branch)

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:17:33 +02:00
Marcel
1803db86b5 test(bulk-edit): plug Sara's identified coverage gaps
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m1s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m54s
- DocumentServiceTest.applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable (Sara C1)
- DocumentServiceTest.findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec (Sara C3)
- bulkSelection.svelte.spec.ts: setAll([]) no-op + previous-IDs-absent + ids getter (Sara C4 + S4)
- /documents/bulk-edit/+page.server.ts now defensively handles a UserGroup
  with NULL `permissions` (treats it as not-WRITE_ALL instead of throwing
  on .includes()) + matching test (Sara C7)

233 backend tests + frontend bulk-edit specs all green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:58:48 +02:00
Marcel
46001bbf9d refactor(documents): extract buildSearchSpec and resolveTags helpers
Markus #3 / Felix B2 — kill the duplicated spec-chain across
findIdsForFilter and searchDocuments, and centralise the
"name string → Tag (find or create)" loop that updateDocumentTags and
applyBulkEditToDocument were each carrying their own copy of.

`buildSearchSpec` is the single source of truth for the seven-spec chain
(text + date range + sender + receiver + tags + tag-prefix + status). Both
callers do their own FTS short-circuit, then delegate.

`resolveTags` is the single source of truth for trimming, blank-skipping,
and find-or-create through TagService. Both updateDocumentTags (replace
semantics) and applyBulkEditToDocument (additive merge) consume it.

No behaviour change. All 231 backend tests still green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:52:38 +02:00
Marcel
af8303dbf8 fix(bulk-edit): auto-clear selection store when leaving /documents and /enrich
Felix C4 — bulkSelectionStore is module-singleton; before this change it
silently followed the user from /documents to /persons / /admin / etc.,
then reappeared as a stale count when they wandered back. Root +layout.svelte
now watches page.url.pathname and clears the store the moment the user
leaves the two routes that surface BulkSelectionBar.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:49:07 +02:00
Marcel
7df00859c6 fix(bulk-edit): pluralization, edit-mode CTA, error UI, real loading state
Elicit C1+C3 — bulk-selection count uses ICU-style plural keys
(bulk_edit_n_selected_one / _other) so n=1 reads as "1 Dokument" instead
of "1 Dokumente". Save CTA in edit mode reads "Anwenden" via the existing
bulk_edit_save_button key; UploadSaveBar grew an editMode prop. Multi-
chunk progress text is now visible (not aria-only).

Felix C2 — bulk-edit page wires the backend error code through
parseBackendError + getErrorMessage instead of falling back to a generic
internal_error.

Felix C5 — editAllMatching no longer swallows fetch failures: the button
shows an inline error with the backend-mapped message (e.g. when the
filter cap is exceeded).

Leonie C8 — replace the literal "…" loading glyph on /documents/bulk-edit
with a spinner + role=status + aria-live=polite + visible "Loading
documents…" text.

Leonie C9 — partial-failure card and bulk-edit page error card now use
the design-system `text-danger` / `bg-danger/10` / `border-danger/40`
tokens (dark-mode safe) instead of raw red palette values.

Leonie C10 + C13 — German plural fixed; EN badges retensed
("+ added" → "+ will be added", "replaced" → "will replace") to match
the future-tense intent of DE/ES.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:46:58 +02:00
Marcel
92d623e298 chore(bulk-edit): bean validation on DTO, readOnly tx, imports
Tobias C2 — DocumentBulkEditDTO carries @Size guards on tagNames (max 200
entries × 200 chars), receiverIds (max 200), and the three location strings
(max 255 chars each). Controller now uses @Valid on @RequestBody so they
fire. The 500-cap on documentIds stays as a controller-level check (typed
BULK_EDIT_TOO_MANY_IDS code, not generic VALIDATION_ERROR).

Markus #7 — replace fully-qualified type names inside DocumentService with
imports (DocumentBatchSummary, DocumentBulkEditDTO).

Markus #8 — @Transactional(readOnly = true) on findIdsForFilter and
batchMetadata. Both are pure read paths; the marker lets Hibernate skip
dirty-checking on the loaded entities.

Record conversion of DocumentBulkEditDTO (Markus #6 / Felix #3) deferred
to a follow-up — keeping @Data avoids 10+ test bodies that mutate the DTO
via setters; the inconsistency is documented in the DTO's class-level
Javadoc.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:41:06 +02:00
Marcel
156efe8b31 fix(bulk-edit): a11y + i18n hardening (Leonie blockers 1–4 + quick concerns)
B1 — i18n the archive-box / archive-folder labels and add helper text.
Karton/Mappe were hardcoded German and broke EN/ES locales (WCAG 3.1.2).

B2 — drop the hardcoded German aria-label on the onboarding callout.
role="note" + the visible localised text is self-describing; the redundant
label was overriding the translated content for AT users on EN/ES.

B3 — Escape clears the bulk selection while the bar is visible. Adds an
"Esc: Auswahl aufheben" hint visible at ≥ sm (WCAG 2.1.1).

B4 — /documents and /enrich reserve pb-32 when the bulk-selection bar is
visible so it doesn't occlude the last row or pagination (WCAG 1.4.10).

Folded in three Leonie quick-concerns:
 - C5: badge text-[10px] → text-[11px], raw text-gray-600 →
        design-token text-ink-2 (dark-mode safe)
 - C7: aria-live="polite" on bulk-selection-count
 - C11: "Alles aufheben" → "Auswahl aufheben" (DE/EN/ES) — disambiguates
        from "discard the operation entirely"

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:35:40 +02:00
Marcel
499beca124 fix(bulk-edit): drop dead initial-* props and clear store on edit-mode discard
Felix B1 — `WhoWhenSection.svelte:37` and `DescriptionSection.svelte:42`
mutated $bindable props at top-level script scope, seeding them from
`initial*` companion props that no caller ever passes. The pattern stomps
parent-owned state in any future component re-evaluation.

Removed the dead initialDateIso / initialLocation / initialDocumentLocation
props and let the bindables carry their own initial value. dateDisplay and
currentTitle now seed from the bindable directly inside untrack — no
re-assignment required.

Elicit B2 — In edit mode the file map IS the user's bulk selection, so
discarding must clear bulkSelectionStore and bounce back to /documents,
otherwise the user is left on /documents/bulk-edit with an empty form
and a stale count in the bottom bar.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:29:44 +02:00
Marcel
5cbb14d4a3 fix(bulk-edit): backend hardening — audit, caps, dedupe, CRLF, WRITE_ALL on /ids
Addresses Markus B1+B2, Nora C1+C4+C5, Tobias #1, Sara B1+B2+C2, Elicit S2+C4
from the cycle 1 review on PR #331.

Audit / version trail
  applyBulkEditToDocument now takes actorId, calls
  documentVersionService.recordVersion(saved), and emits an
  AuditKind.METADATA_UPDATED event tagged source=BULK_EDIT — restoring parity
  with the single-doc updateDocument path.

Caps
  /api/documents/batch-metadata: 500-ID cap (matches PATCH cap)
  /api/documents/ids: 5000 result cap with BULK_EDIT_TOO_MANY_IDS on overflow

Permission tightening
  /api/documents/ids re-gated WRITE_ALL — its only consumer is the bulk-edit
  fast path (least-privilege per Elicit S2 + Nora's defence-in-depth).

Audit log
  /ids and /batch-metadata now emit one log.info per call, mirroring the
  quickUpload + bulkEdit format.

Robustness
  Duplicates in PATCH documentIds are de-duplicated via LinkedHashSet so a
  double-clicked "Alle X editieren" cannot inflate the updated count.
  log.warn lines that interpolate Throwable.getMessage() now run through a
  CRLF-strip helper (CWE-117).

Tests added
  applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit
  patchBulk_acceptsExactly500Ids_atTheCap (off-by-one fence)
  patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount
  getDocumentIds_returns403_forUserWithoutWriteAll
  getDocumentIds_returns400_whenResultExceedsFilterCap
  batchMetadata_returns403_forUserWithoutReadAll
  batchMetadata_returns400_whenIdsExceedsCap

All 231 backend tests green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:24:03 +02:00
Marcel
2bb8fb8968 fix(bulk-edit): align BulkEditEntry shape with backend DocumentBatchSummary
Production bug — the backend serialises the document UUID as `id`, but
BulkEditEntry typed it as `documentId`. The runtime cast in /documents/
bulk-edit/+page.svelte was a TypeScript lie: every `entry.documentId`
became undefined, the SvelteMap collapsed all selections under the
undefined key, and the PATCH fired with `documentIds: []` (which the
controller correctly rejected with 400). Field semantics ACs could
therefore never fire end-to-end.

Renamed `BulkEditEntry.documentId` → `id`. The FileEntry built from each
summary still carries both `id` (local map key) and `documentId` (PATCH
payload) so the save handler is unchanged.

Reported by Elicit (B1) on PR #331.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:14:53 +02:00
Marcel
f13f635161 test(bulk-edit): e2e coverage for selection bar and Massenbearbeitung flow
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m0s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m53s
CI / Unit & Component Tests (pull_request) Failing after 3m0s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
Five Playwright scenarios on the bulk-edit feature:
 - sticky bar appears with count when checkboxes are toggled
 - Alles aufheben hides the bar
 - Massenbearbeitung navigates to /documents/bulk-edit and the edit-mode
   onboarding callout is rendered
 - direct navigation to /documents/bulk-edit with no selection redirects back
 - the same bar drives /enrich (skipped when the test DB has no incomplete docs)

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:30:18 +02:00
Marcel
6d3489d035 feat(bulk-edit): add /documents/bulk-edit route
Server load redirects READ_ALL-only users (or unauthenticated) to /documents.
Page load: onMount reads bulkSelectionStore — redirects to /documents when the
store is empty, otherwise POSTs the IDs to /api/documents/batch-metadata and
hands the resulting summaries to BulkDocumentEditLayout in mode="edit".

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:18:07 +02:00
Marcel
fa5dc43864 feat(bulk-edit): extend BulkDocumentEditLayout with mode="edit"
- New FieldLabelBadge component (additive / replace variants, WCAG AA contrast)
- WhoWhenSection: hideDate prop, editMode prop renders badges next to sender
  and receivers, hides the meta_location field
- DescriptionSection: editMode prop renders badges next to tags and archive
  fields; new bindable archiveBox / archiveFolder inputs only in editMode
- PersonTypeahead: optional badge prop forwards to FieldLabelBadge
- FileSwitcherStrip FileEntry: file is now optional, documentId added so
  edit-mode entries reference an existing document by UUID
- BulkDocumentEditLayout: mode prop branches drop zone / read-only title /
  callout / save handler. Edit save chunks 500 IDs per PATCH, stops on chunk
  failure with retry, marks per-document errors as chips, clears the bulk
  selection store on full success.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:16:06 +02:00
Marcel
d4f32ed5d4 feat(bulk-edit): add BulkSelectionBar and Alle-X-editieren fast path
- BulkSelectionBar component: sticky bottom bar shown only when canWrite
  and selection is non-empty. Buttons meet WCAG 44px touch targets and
  iOS safe-area inset is honoured.
- Bar mounted on /documents and /enrich.
- Alle X editieren button on /documents replaces the selection with
  every UUID matching the active filter (via /api/documents/ids) and
  jumps to /documents/bulk-edit.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:07:26 +02:00
Marcel
27e3d290e7 feat(bulk-edit): add canWrite-gated row checkboxes on /documents and /enrich
Each row in the document search list and the enrichment queue gets a
WCAG-compliant (44px touch target) checkbox bound to bulkSelectionStore.
Checkbox click does not trigger the row's stretched-link navigation —
it sits inside the z-10 content sibling, the link is in the z-0 sibling,
so click events do not bubble between them.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:03:59 +02:00
Marcel
25446c9a5c feat(bulk-edit): add bulkSelection store backed by SvelteSet
Module-singleton live accumulator: selection persists across pagination
and route changes within /documents and /enrich. Cleared on successful
bulk save or via Alles aufheben.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:54:59 +02:00
Marcel
660e34e016 feat(bulk-edit): add i18n keys, error mapping, and regenerate api types
- 14 new Paraglide keys in de/en/es for the bulk-edit UI strings (selection
  bar, callout, badges, save progress, retry, error)
- BULK_EDIT_TOO_MANY_IDS added to errors.ts type union and getErrorMessage()
- Regenerated api.ts now includes /api/documents/{bulk,batch-metadata,ids}
  and the DocumentBulkEditDTO / BulkEditResult / DocumentBatchSummary schemas

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:52:10 +02:00
Marcel
b662117e55 feat(bulk-edit): add GET /api/documents/ids endpoint
READ_ALL-gated endpoint returning all document UUIDs matching the same
filter parameters as /search, ignoring page/size. Powers the "Alle X
editieren" fast path so the bulk-edit page can replace the selection
with every match in one round-trip.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:40:56 +02:00
Marcel
d251806e72 feat(bulk-edit): add POST /api/documents/batch-metadata endpoint
READ_ALL-gated batch endpoint returning lightweight summaries (id, title,
server PDF URL) for the bulk-edit page's left strip. Unknown IDs are silently
dropped — missing previews would be obvious to the user already.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:38:08 +02:00
Marcel
f0da033ec9 feat(bulk-edit): add PATCH /api/documents/bulk endpoint
WRITE_ALL-gated batch endpoint that applies a partial DTO to up to 500
documents per request. Per-document failures (DOCUMENT_NOT_FOUND, etc.)
are collected into the response's errors[] without aborting the batch.
Logs an audit line consistent with quickUpload.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:34:52 +02:00
Marcel
a59feec81a feat(bulk-edit): add DocumentService.applyBulkEditToDocument
Per-document atomic mutation method for the upcoming bulk PATCH endpoint.
Tags and receivers merge additively into existing sets; sender and the three
location fields replace only when the DTO field is non-blank. Wrapped in its
own @Transactional so a per-document failure cannot partially mutate other
documents in the outer batch loop.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:31:48 +02:00
Marcel
779ffaab55 feat(bulk-edit): scaffold DTOs and BULK_EDIT_TOO_MANY_IDS error code
Adds the request/response shapes for the upcoming PATCH /api/documents/bulk,
POST /api/documents/batch-metadata, and the new error code for the 500-ID cap.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:27:46 +02:00
Marcel
b690c74ddf fix(richtlinien): improve examples, copy, and Wikipedia link
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m57s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m59s
- Rule cards now show before→after examples; strikethrough rule input
  renders with CSS line-through so the visual context is honest
- Illegible-words rule shows output only — can't represent unreadable
  text as readable characters
- Intro drops fictional family names in favour of "egal wer tippt"
- Wikipedia card copy is more direct; link uses icon instead of
  parenthetical "(öffnet in neuem Tab)" text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:10:56 +02:00
Marcel
0797406f02 docs(bulk-upload): explain chunkSize=10 and 50-file cap constants
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m47s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
c94d2cec03 feat(bulk-upload): guard discard-all with confirm dialog
Uses getConfirmService() (optional — null fallback when context is absent so
unit tests that don't exercise the discard path need no CONFIRM_KEY context)
and the new bulk_discard_confirm i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
4da0bf71a0 fix(bulk-upload): add gradient overflow indicators to chip strip
Adds pointer-events-none left/right gradient fade overlays on the
FileSwitcherStrip track div so mouse-only users can see when more
chips are hidden beyond the visible area. The scrollbar is hidden
(scrollbar-width:none) so gradients are the only overflow signal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
da5d3c60b3 fix(bulk-upload): chip readability and focus management in FileSwitcherStrip
Chip label text increased from 11px to 12px (text-xs) and number badge
from 9px to 11px for the 60+ senior audience on laptops/tablets.

After removing a chip via the × button, focus moves to the previous chip
(falling back to the next chip when the first chip is removed) so keyboard
users are not stranded on <body>. Uses Svelte tick() to wait for DOM update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ed0d0bf331 fix(bulk-upload): handle network errors and partial upload success
save() now wraps each chunk fetch in try/catch — a thrown network error
marks all files in that chunk as errored. Also handles HTTP 200 responses
with a non-empty errors array (partial success): only the named filenames
are marked as errored rather than all files in the chunk. Navigation is
suppressed whenever any file fails.

Tests added:
- network error marks all chunk files as errored, no navigation
- HTTP 200 with errors array marks only affected files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
899508f9ca feat(bulk-upload): guard save() against concurrent invocations
Adds a saving $state flag that blocks re-entry while a chunk upload is
in flight. The UploadSaveBar save button is disabled via a new disabled
prop while saving is true. Tested: clicking Save twice fires fetch only
once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d32e671e9d fix(bulk-upload): raise discard button touch target to 44px for WCAG compliance
Senior users on tablets need at least 44×44px touch targets (WCAG 2.2).
Added min-h-[44px] flex items-center px-2 to the discard button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b61cfa081f test(bulk-upload): add positive navigation assertion for successful save
The error-path test (goto not called on failure) had no matching positive
assertion. Added: save() navigates to /documents when all chunks succeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d914385afc fix(bulk-upload): correct stale DocumentBatchMetadataDTO type in api.ts
Generated type had tags?: string but Java DTO declares List<String> tagNames.
Corrected to tagNames?: string[] to match the backend contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
6cdfc1f6a3 fix(bulk-upload): announce error chip status to screen readers
The ! indicator was aria-hidden with no sr-only fallback, making failed
uploads invisible to assistive technology. Added sr-only span with
bulk_file_error_chip_label before the visual indicator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ed6a2fb56f test(bulk-upload): fix ScopeCard spec assertions to match actual component classes
/brand-mint/ never matched (component uses border-accent bg-accent-bg);
companion test also updated to assert the meaningful negative.
getByText('5') fixed to exact:true to avoid strict-mode ambiguity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
58545876cd fix(bulk-upload): accessibility improvements and fetch comment
- BulkDropZone: link description <p> to drop zone region via aria-describedby
- UploadSaveBar: add explicit aria-valuenow/aria-valuemin/aria-valuemax to
  <progress> element for consistent screen reader support across browsers
- FileSwitcherStrip: add non-color error indicator (red !) to error chips so
  error state is not communicated by color alone (WCAG 1.4.1)
- BulkDocumentEditLayout: comment explaining why raw fetch is used instead of
  a SvelteKit form action (chunked FormData with per-chunk progress tracking)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
687ebf495d fix(bulk-upload): match error chips by filename, not by chunk position
save() was marking the first N files in a chunk as errored (where N = the
error count returned by the backend), but the backend errors are keyed by
filename. A failure for file[2] would incorrectly mark file[0] as the error.

Now builds a Set of error filenames and matches chunk entries by file.name.
Test added: save marks only the file whose filename matches the backend error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
bc10f2af06 fix(i18n): remove orphaned merge conflict markers from message files
All three message files had a bare `<<<<<<< HEAD` at line 814 with no
corresponding separator or closing marker, making them invalid JSON and
breaking the Paraglide build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
0bfd342190 test(bulk-upload): add unit tests for storeDocumentWithBatchMetadata
Covers four behaviours of applyBatchMetadata that had no coverage:
title applied by list index, sender resolved via PersonService,
tags applied via updateDocumentTags, and title left unchanged when
the fileIndex exceeds the titles list length.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
1973f88e56 fix(bulk-upload): truncate long chip titles with tooltip in FileSwitcherStrip
Long filenames caused chips to overflow the strip. Added max-w-[8rem]
and truncate on the title span, plus a title attribute for full text
on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
9f044f429c fix(bulk-upload): enlarge scroll button touch targets to 44×44px
Prev/next scroll buttons were 24×20px, below the WCAG 2.2 SC 2.5.5
minimum of 44×44px. Changed to h-[44px] w-[44px].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
7ad5e35fd6 fix(bulk-upload): populate aria-live region with active file title
The sr-only aria-live div was always empty, so screen readers never
announced file switches. Derived activeAnnouncement from the active
entry and bound it to the div's text content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
e7afed5ac3 fix(bulk-upload): add aria-label to progress bar in UploadSaveBar
<progress> had no accessible name, failing WCAG 1.3.1 and 4.1.2.
Labels it with the already-existing bulk_upload_progress i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f48d1e3cd8 fix(bulk-upload): i18n topbar title; replace hardcoded German strings
'Neues Dokument' / 'Neue Dokumente' in BulkDocumentEditLayout topbar
bypassed Paraglide. Added bulk_title_single and bulk_title_multi keys
to de/en/es message files and switched to m.*() calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
fc118f7032 fix(bulk-upload): skip navigation when any chunk fails to upload
goto('/documents') fired unconditionally, discarding error chips and
leaving the user with no feedback on which files failed. Now only
navigates when hadErrors is false after all chunks complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
4229e952fb fix(bulk-upload): include tagNames in quick-upload metadata payload
Tags were silently dropped because the metadata object built in save()
never included a tagNames field; they never reached the backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
e1259215ef test(bulk-upload): add save-error and discard-all coverage to BulkDocumentEditLayout spec
- save error path: server returns non-ok → fetch is called (error handling wired)
- discard-all: N=2 → click topbar button → N=0 drop-zone restored, switcher gone
- Add data-testid="discard-all-btn" to topbar discard button for reliable selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f06d034b36 fix(bulk-upload): i18n hardcoded strings in BulkDropZone and FileSwitcherStrip
- Add bulk_drop_desc, bulk_select_files, bulk_drop_zone_label, bulk_remove_file
  keys to de/en/es message files
- BulkDropZone: use m.bulk_drop_zone_label(), m.bulk_drop_desc(),
  m.bulk_select_files() — removes all hardcoded German
- FileSwitcherStrip: use m.bulk_remove_file() on × button; move aria-live
  from <ul> to a dedicated visually-hidden region above the strip (screen
  readers now announce changes without coupling the live region to the list)
- Spec: import FileEntry from component instead of re-declaring; use
  data-remove-id selector instead of hardcoded German aria-label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
a6cd10f219 refactor(documents): extract applyBatchMetadata private helper in DocumentService
storeDocumentWithBatchMetadata was a 30-line flat method mixing file storage
with metadata hydration. The private helper makes each concern visible at a
glance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b8e6fe9ec9 refactor(documents): change DocumentBatchMetadataDTO.tags from String to List<String> tagNames
Replaces comma-delimited String with a proper JSON array field — callers no
longer need to pre-serialise. Service drops the split/trim/filter step and
passes tagNames directly to updateDocumentTags().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
763f1990cd refactor(documents): move batch validation from controller into DocumentService
Validation guards (BATCH_TOO_LARGE, titles > files) are domain rules and
belong in the service where they can be unit-tested without the HTTP layer.
Controller now delegates to documentService.validateBatch().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ca62f50921 fix(forms): remove autofocus from WhoWhenSection entirely
The autofocus prop was added conditionally but still triggered on the
bulk-upload page. Removing it completely — callers that need focus
management can handle it independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
61f84a86ac fix(forms): apply py-3 to location input for consistent 44px height
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
0eb5c95c6c fix(forms): raise date and sender field height to match receiver (44px)
PersonMultiSelect naturally renders at 44px due to nested padding (outer p-2 + inner p-1).
Apply py-3 px-2 to the date input and PersonTypeahead default mode so all three fields
align visually.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d662635392 fix(PersonTypeahead): match height and border-radius of other form inputs
Default mode was text-base (16px) and rounded-md — date field uses text-sm
(14px) and rounded. Aligning these makes Sender/Date/Receiver rows consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b00be2548c fix(PersonMultiSelect): align height and focus ring with other form inputs
min-h-[42px] → min-h-[38px] to match p-2 text-sm input height.
Add shadow-sm (was missing vs date/sender inputs).
focus-within:ring-1 ring-ink → focus-within:ring-2 ring-focus-ring to match
the focus style used consistently across all other form inputs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
01a8654347 fix(bulk-upload): no layout shift, no autofocus on date field
Replace JS navHeight measurement with CSS var(--header-height) so the fixed
panel renders in its final position on first paint — no onMount shift.

Add autofocus prop to WhoWhenSection (default true, preserves document-edit
behaviour) and pass autofocus={false} from BulkDocumentEditLayout so the date
field does not steal focus before the user has even dropped any files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
c1b221412f fix(bulk-upload): PDF-only file acceptance
Drop non-PDF accept types from file input and update format hint strings
in all three languages. JPEG/PNG/TIFF were never officially supported.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
76c14ea604 fix(bulk-upload): form layout polish and drop zone sizing
- Drop zone box doubled: max-w-xl, larger icon (80px), bigger padding and text
- Title field wrapped in its own card (matches WhoWhenSection/DescriptionSection)
- Removed double-wrapping outer card around WhoWhenSection + DescriptionSection
- Added space-y-4 between form sections for consistent breathing room
- ScopeCard per-file label: text-accent → text-primary for legible contrast in light theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
539842e849 fix(bulk-upload): spec-compliant split-panel layout with local PDF preview
Rewrites BulkDocumentEditLayout to match the spec exactly:
- Fixed viewport layout (same as DocumentEditLayout) filling viewport below nav
- Split panel visible in all states (N=0/1/≥2) — was fullscreen dark drop zone
- N=0: centered drop-zone-box in left panel; shared form visible but greyed out
- N≥1: real PDF preview via URL.createObjectURL (no server upload required)
- N≥2: FileSwitcherStrip at bottom of left panel; count pill + discard in topbar
- FileEntry gains previewUrl; blob URLs created on add, revoked on remove/destroy
- save() checks response.ok and marks failed files with status: 'error'
- BulkDropZone redesigned: spec-accurate box with circular mint icon, serif title
- FileSwitcherStrip: number badges, arrows, keyboard nav via data-chip-id selector
- ScopeCard, UploadSaveBar: hardcoded German replaced with Paraglide i18n keys
- +page.svelte simplified to bare component render (layout is self-contained)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ef7a51fe30 chore(api): regenerate types — adds DocumentBatchMetadataDTO
New type from the bulk-upload metadata part added in #317.
Generated from backend running with --spring.profiles.active=dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ec17cb123a feat(bulk-upload): wire /documents/new to BulkDocumentEditLayout
Replaces the single-file form-action flow with BulkDocumentEditLayout,
enabling multi-file drag-and-drop upload with local preview, per-file
title editing, and shared metadata. Server load function unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
801470093d feat(bulk-upload): add BulkDocumentEditLayout component with save handler
State-owner for the bulk upload flow:
- N=0: full-panel BulkDropZone
- N=1: title + shared metadata (no switcher/scope cards)
- N≥2: FileSwitcherStrip + per-file ScopeCard + shared ScopeCard
Save handler chunks files at 10/request, POSTs to /api/documents/quick-upload
with typed metadata JSON part, tracks progress, redirects to /documents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
af6ba6a9cc feat(bulk-upload): add UploadSaveBar component + fix bulk_save_cta message
Save bar with sticky positioning, a determinate progress bar while
uploading chunks, plural save CTA, and a destructive discard link.
Replaces broken ICU plural in bulk_save_cta with two-key approach
(bulk_save_cta_one / bulk_save_cta) since Paraglide 2.5 does not support
ICU plural syntax.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
9acd5ec617 feat(bulk-upload): add ScopeCard component
Card container with two variants: per-file (mint tint) and shared (neutral
with file-count badge). Used to visually separate per-file vs shared
metadata sections in the bulk upload layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
29a44b3cd1 feat(bulk-upload): add FileSwitcherStrip component
Horizontal chip strip for switching between files in a bulk upload session.
Supports keyboard navigation (arrow keys cycle within the strip), error state
chips, and onSelect/onRemove callbacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
5fe289b06b feat(bulk-upload): add BulkDropZone component
Full-panel drop target that supports multi-file selection via drag-and-drop
or file picker. Fires onFilesAdded callback with the full File array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f76af8c678 feat(bulk-upload): add bulkTitleFromFilename utility
Converts a raw filename into a human-readable title candidate by
stripping the extension and replacing underscore/hyphen runs with spaces.
Reuses the existing stripExtension() helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
69c739c6e3 feat(i18n): add BATCH_TOO_LARGE error code + 16 bulk-upload Paraglide keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
43cf022f05 feat(documents): extend quick-upload with optional batch metadata part
- Add DocumentBatchMetadataDTO (titles, senderId, receiverIds, documentDate, location, tags, metadataComplete)
- Add BATCH_TOO_LARGE to ErrorCode
- Extend quickUpload to accept optional @RequestPart("metadata"); dispatches to storeDocumentWithBatchMetadata when present
- Cap batch at 50 files/request; reject 400 when titles.size > files.size
- Add DocumentService.storeDocumentWithBatchMetadata applying shared fields + index-based titles to both created and updated docs
- Raise max-request-size to 500MB (10-file chunk at max per-file size)
- Add structured SLF4J logging for every quickUpload call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
48d034dcb8 fix(transcribe-coach): propagate hover from 44px button group to inner span
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
hover: on the <span> only fired on the 20×20px visual circle, not the
full 44×44px touch target. Add `group` + `focus-visible:ring-*` to the
outer button; switch to `group-hover:` on the inner span so the visual
response covers the entire interactive area.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
c335ddd686 test(e2e): add training footer positive-case test and fix broken selectors
- createEmptyDocument now uploads a minimal PDF so the Transkribieren
  button is rendered (requires isPdf = true in DocumentTopBar)
- add 'Transcribe coach — with blocks' describe: seeds a block via API,
  waits for blocks to settle in read mode, switches to edit, confirms
  'Für Training vormerken' is visible
- fix dark-theme axe test: ThemeToggle uses aria-label 'dark mode',
  not the previous /Farbmodus|theme/ regex

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
7830a749a0 docs(richtlinien): shorten prerender comment to essentials
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
5b7c37391c test(HelpPopover): use userEvent.keyboard for Enter/Space tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
ce72b07197 test(e2e): fix locators, add print assertion, cleanup, remove redundant emulateMedia
- help-popover: replace broad button[aria-expanded] with specific
  getByLabel('Lese- und Bearbeitungsmodus'); update role="tooltip" →
  role="region"; add afterAll doc cleanup (Sara/Tobias)
- richtlinien: assert .new-tab spans are hidden in print media — the
  existing test only checked .app-nav (Sara)
- transcribe-coach: remove 4× redundant page.emulateMedia({reducedMotion})
  calls — playwright.config.ts already sets reducedMotion: 'reduce' globally;
  add afterAll doc cleanup (Tobias)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
505804c893 chore(i18n): remove dead transcription_empty_draw_hint key
The key was orphaned when TranscriptionEditView's empty state was replaced
by TranscribeCoachEmptyState. Removed from de/en/es to avoid accumulating
unreferenced strings. (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
67421a4c0c docs(richtlinien): document why prerender=true is auth-safe
handleAuth in hooks.server.ts is in the sequence() chain and redirects
unauthenticated users at runtime regardless of prerender. Adding a comment
so the next reader doesn't mistake this for a security hole. (Markus/Nora)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
0ea0df4f72 fix(richtlinien): <main> landmark + closing card h2 → h3
- Wrap page content in <main> so AT users can jump to main content (Nora)
- Closing card "Fehlt eine Regel?" was <h2> after two existing <h2> siblings
  but styled like a card title, not a section label; downgrade to <h3> to
  fix the heading hierarchy (Sara/Leonie)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
077f5c85df fix(TranscribeCoachEmptyState): Tailwind grid instead of inline styles; step aria-labels
- Replace style="grid-template-columns: 34px 1fr; align-items: start;"
  with Tailwind grid-cols-[34px_1fr] items-start (Felix: inline styles)
- Add aria-label="Schritt N von 3" on each <li> so screen readers announce
  step position when the numeric badge is aria-hidden (Nora/Sara)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
018e272a3b fix(RichtlinienRuleCard): bg-[#FAF8F1] → bg-parchment design token
Raw hex bypassed the token system and wouldn't remap in dark mode.
Now uses --color-parchment which has a proper dark-mode counterpart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
0c4a0ead7b fix(TranscribeDragDemo): reactive prefersReducedMotion + bg-parchment token
- Replace one-shot $derived(.matches) snapshot with $state + addEventListener
  so the static/animated branch reacts when the user toggles OS reduced-motion
  at runtime (Felix: non-reactive media query)
- Replace bg-[#FAF8F1] raw hex with bg-parchment design token so the SVG
  background remaps correctly in dark mode (Felix/Markus)

Also update TranscriptionPanelHeader.svelte.test.ts to expect role="region"
after the HelpPopover ARIA fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
82b12d4383 fix(HelpPopover): role=region, 44px touch target, counter-based ID
- role="tooltip" → role="region" + aria-label={label}: tooltip semantics
  are wrong for a click-triggered panel (Nora/Sara)
- expand button to 44×44px with inner visual <span>: WCAG 2.5.8 touch
  target for 60+ transcriber audience (Sara/Leonie)
- replace Math.random() with module-level counter: SSR/hydration mismatch
  when server and client generate different IDs (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
01758e8e00 feat(tokens): add --color-parchment design token for warm example-block surfaces
Adds --c-parchment (#faf8f1 light / #041828 dark) to :root and both
dark-mode blocks, exposed as --color-parchment via @theme inline.
Prerequisite for replacing bg-[#FAF8F1] raw-hex in RichtlinienRuleCard
and TranscribeDragDemo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
116 changed files with 5415 additions and 552 deletions

View File

@@ -0,0 +1,3 @@
### Mark all blocks as reviewed
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
Authorization: Basic admin admin123

View File

@@ -26,7 +26,16 @@ public enum AuditKind {
COMMENT_ADDED,
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
MENTION_CREATED;
MENTION_CREATED,
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
USER_CREATED,
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
USER_DELETED,
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
GROUP_MEMBERSHIP_CHANGED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,

View File

@@ -1,5 +1,7 @@
package org.raddatz.familienarchiv.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -197,4 +199,6 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ORDER BY ranked.document_id, ranked.rn
""", nativeQuery = true)
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
}

View File

@@ -1,11 +1,17 @@
package org.raddatz.familienarchiv.audit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.*;
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
@Service
@RequiredArgsConstructor
public class AuditLogQueryService {
@@ -51,6 +57,11 @@ public class AuditLogQueryService {
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
}
public List<AuditLog> findRecentUserManagementEvents(int limit) {
PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending());
return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent();
}
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {

View File

@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
boolean existsByKind(AuditKind kind);
}

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -13,12 +14,18 @@ import java.util.UUID;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.validation.annotation.Validated;
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
import org.raddatz.familienarchiv.dto.BulkEditError;
import org.raddatz.familienarchiv.dto.BulkEditResult;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.TagOperator;
@@ -237,6 +244,100 @@ public class DocumentController {
return new QuickUploadResult(created, updated, errors);
}
// --- BULK EDIT ---
private static final int BULK_EDIT_MAX_IDS = 500;
/** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
* call from materialising the entire {@code documents} table into JSON.
* Generous enough for real-world "Alle X editieren" against the family
* archive's bounded scale (~1500 docs today, expected growth to ~5k). */
private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;
@PatchMapping("/bulk")
@RequirePermission(Permission.WRITE_ALL)
public BulkEditResult patchBulk(
@RequestBody @Valid DocumentBulkEditDTO dto,
Authentication authentication) {
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
}
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
}
UUID actorId = requireUserId(authentication);
int updated = 0;
List<BulkEditError> errors = new ArrayList<>();
// Dedupe duplicate document IDs while preserving submission order. A
// double-click on "Alle X editieren" would otherwise hit each document
// twice and inflate the `updated` count returned to the user.
LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());
for (UUID id : uniqueIds) {
try {
documentService.applyBulkEditToDocument(id, dto, actorId);
updated++;
} catch (DomainException e) {
errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
} catch (Exception e) {
errors.add(new BulkEditError(id, "Internal error"));
log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
}
}
log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());
return new BulkEditResult(updated, errors);
}
/** CRLF strip for any log line interpolating a free-form string (e.g.
* {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
private static String sanitizeForLog(String s) {
return s == null ? null : s.replaceAll("[\\r\\n]", "_");
}
@GetMapping("/ids")
@RequirePermission(Permission.WRITE_ALL)
public List<UUID> getDocumentIds(
@RequestParam(required = false) String q,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to,
@RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags,
@RequestParam(required = false) String tagQ,
@RequestParam(required = false) DocumentStatus status,
@RequestParam(required = false) String tagOp,
Authentication authentication) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
}
UUID actorId = requireUserId(authentication);
log.info("documentIds actor={} matched={}", actorId, ids.size());
return ids;
}
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePermission(Permission.READ_ALL)
public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
if (request == null || request.ids() == null || request.ids().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
}
if (request.ids().size() > BULK_EDIT_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
}
UUID actorId = requireUserId(authentication);
log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
return documentService.batchMetadata(request.ids());
}
@GetMapping("/incomplete-count")
@RequirePermission(Permission.WRITE_ALL)
public Map<String, Long> getIncompleteCount() {

View File

@@ -63,27 +63,33 @@ public class PersonController {
@PostMapping
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
validatePersonNames(dto);
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
return ResponseEntity.ok(personService.createPerson(dto));
}
@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
validatePersonNames(dto);
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
return ResponseEntity.ok(personService.updatePerson(id, dto));
}
private void validatePersonNames(PersonUpdateDTO dto) {
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
}
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
}
}
@PostMapping("/{id}/merge")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)

View File

@@ -90,6 +90,15 @@ public class TranscriptionBlockController {
return transcriptionService.reviewBlock(documentId, blockId, userId);
}
@PutMapping("/review-all")
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> markAllBlocksReviewed(
@PathVariable UUID documentId,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.markAllBlocksReviewed(documentId, userId);
}
@GetMapping("/{blockId}/history")
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory(

View File

@@ -78,24 +78,31 @@ public class UserController {
@PostMapping("/users")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(request));
public ResponseEntity<AppUser> createUser(Authentication authentication,
@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
}
@PutMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
@PathVariable UUID id,
@RequestBody AdminUpdateUserRequest dto) {
AppUser updated = userService.adminUpdateUser(id, dto);
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
userService.deleteUser(id);
public ResponseEntity<Void> deleteUser(Authentication authentication,
@PathVariable UUID id) {
userService.deleteUser(actorId(authentication), id);
return ResponseEntity.ok().build();
}
private UUID actorId(Authentication auth) {
return userService.findByEmail(auth.getName()).getId();
}
}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import java.util.List;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
public record BatchMetadataRequest(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<UUID> ids) {}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
public record BulkEditError(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
public record BulkEditResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int updated,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<BulkEditError> errors) {}

View File

@@ -0,0 +1,10 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
public record DocumentBatchSummary(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String pdfUrl) {}

View File

@@ -0,0 +1,60 @@
package org.raddatz.familienarchiv.dto;
import java.util.List;
import java.util.UUID;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Request body for {@code PATCH /api/documents/bulk}. Field semantics:
* <ul>
* <li>{@code tagNames} and {@code receiverIds} are <b>additive</b> —
* merged into each document's existing set, never replacing it.</li>
* <li>{@code senderId}, {@code documentLocation}, {@code archiveBox},
* {@code archiveFolder} are <b>replace-on-non-blank</b> — null/blank
* fields are skipped, anything else overwrites.</li>
* </ul>
*
* <p>Kept as a Lombok {@code @Data} POJO (not a record) for symmetry with
* the existing {@code DocumentUpdateDTO} and to keep test setup terse —
* the per-feature DTOs introduced alongside this one ({@link BulkEditError},
* {@link BulkEditResult}, {@link BatchMetadataRequest},
* {@link DocumentBatchSummary}) <i>are</i> records because they have no
* test-side mutation. Tracked in the cycle-1 review for follow-up.
*
* <p>Bean-validation caps below defend against payload-amplification: the
* 1 MiB SvelteKit proxy cap allows ~26k UUIDs through to the backend, and
* Jetty's default body limit is 8 MB. {@code @Size} guards catch malformed
* clients without depending on those outer bounds.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentBulkEditDTO {
// No @Size cap here on purpose: the controller's BULK_EDIT_MAX_IDS check
// returns the typed BULK_EDIT_TOO_MANY_IDS error code, which the frontend
// maps to a localised "Maximal 500 …" message via Paraglide. A bean-
// validation @Size would short-circuit that with a generic VALIDATION_ERROR.
private List<UUID> documentIds;
@Size(max = 200, message = "tagNames must not exceed 200 entries")
private List<@Size(max = 200, message = "tagName must not exceed 200 chars") String> tagNames;
private UUID senderId;
@Size(max = 200, message = "receiverIds must not exceed 200 entries")
private List<UUID> receiverIds;
@Size(max = 255, message = "documentLocation must not exceed 255 chars")
private String documentLocation;
@Size(max = 255, message = "archiveBox must not exceed 255 chars")
private String archiveBox;
@Size(max = 255, message = "archiveFolder must not exceed 255 chars")
private String archiveFolder;
}

View File

@@ -13,6 +13,8 @@ public class DocumentUpdateDTO {
private LocalDate documentDate;
private String location;
private String documentLocation;
private String archiveBox;
private String archiveFolder;
private String transcription;
private String summary;
private UUID senderId;

View File

@@ -1,10 +1,14 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.raddatz.familienarchiv.model.PersonType;
@Data
public class PersonUpdateDTO {
@NotNull
private PersonType personType;
@Size(max = 50)
private String title;
@Size(max = 100)

View File

@@ -13,6 +13,8 @@ public enum ErrorCode {
PERSON_NOT_FOUND,
/** A person name alias with the given ID does not exist. 404 */
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
@@ -111,6 +113,8 @@ public enum ErrorCode {
VALIDATION_ERROR,
/** Batch upload exceeds the maximum allowed file count per request. 400 */
BATCH_TOO_LARGE,
/** Bulk edit request exceeds the per-request document ID cap. 400 */
BULK_EDIT_TOO_MANY_IDS,
/** An unexpected server-side error occurred. 500 */
INTERNAL_ERROR,
}

View File

@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
SELECT d.id FROM documents d
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('german', regexp_replace(
THEN to_tsquery('simple', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
FROM documents d
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('german', regexp_replace(
THEN to_tsquery('simple', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',

View File

@@ -8,6 +8,8 @@ import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
@@ -269,6 +271,8 @@ public class DocumentService {
doc.setTranscription(dto.getTranscription());
doc.setSummary(dto.getSummary());
doc.setDocumentLocation(dto.getDocumentLocation());
doc.setArchiveBox(dto.getArchiveBox());
doc.setArchiveFolder(dto.getArchiveFolder());
List<String> tags = new ArrayList<>();
if (dto.getTags() != null && !dto.getTags().isBlank()) {
@@ -334,20 +338,143 @@ public class DocumentService {
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
doc.setTags(resolveTags(tagNames));
return documentRepository.save(doc);
}
Set<Tag> newTags = new HashSet<>();
/**
* Resolves a list of tag-name strings to {@link Tag} entities, trimming
* whitespace and skipping blank entries. Single source of truth for
* "name string → Tag" so the find-or-create policy stays consistent
* across single-doc updates ({@link #updateDocumentTags}), bulk edits
* ({@link #applyBulkEditToDocument}), and the upload-batch path
* ({@code applyBatchMetadata}).
*/
private Set<Tag> resolveTags(List<String> tagNames) {
if (tagNames == null || tagNames.isEmpty()) return new HashSet<>();
Set<Tag> resolved = new HashSet<>();
for (String name : tagNames) {
// Clean the string
String cleanName = name.trim();
if (cleanName.isEmpty())
continue;
if (cleanName.isEmpty()) continue;
resolved.add(tagService.findOrCreate(cleanName));
}
return resolved;
}
newTags.add(tagService.findOrCreate(cleanName));
/**
* Returns all document IDs matching the given filter parameters, ignoring
* pagination. Used by the bulk-edit "Alle X editieren" fast path so the
* frontend can replace the selection with every match across pages in one
* round-trip.
*/
@Transactional(readOnly = true)
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
}
doc.setTags(newTags);
return documentRepository.save(doc);
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
}
/**
* Single source of truth for the search Specification chain. Shared by
* {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter}
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
* full-text query returned no rows.
*/
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to,
UUID sender, UUID receiver,
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator) {
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
return Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
}
/**
* Returns lightweight summaries (id, title, server PDF URL) for the given
* document IDs. Unknown IDs are silently dropped — the consumer is the
* bulk-edit page's left strip, where missing previews would already be
* obvious; surfacing them as errors here adds no value.
*/
@Transactional(readOnly = true)
public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return List.of();
return documentRepository.findAllById(ids).stream()
.map(d -> new DocumentBatchSummary(
d.getId(),
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
"/api/documents/" + d.getId() + "/file"))
.toList();
}
/**
* Applies a bulk-edit DTO to a single document atomically.
* Tags and receivers are additive (merged into existing sets); sender and the
* three location fields are replace-on-non-blank (null/blank means "no change").
* Wrapped in its own transaction so a failure on one document never partially
* mutates another in the controller's batch loop.
*
* Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit
* event tagged {@code source=BULK_EDIT} and writes a row to
* {@code document_versions} so the family archive's "who changed what"
* trail stays complete across both single- and bulk-doc edit paths.
*
* NOTE on N+1: tag and person resolution happens per-document. With 500
* documents × 10 tags this fans out to ~5000 tag-resolve queries per
* request. Acceptable today because the family archive is bounded at
* ~1500 documents total. Tracked as a perf follow-up.
*/
@Transactional
public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
Set<Tag> merged = new HashSet<>(doc.getTags());
merged.addAll(resolveTags(dto.getTagNames()));
doc.setTags(merged);
}
if (dto.getSenderId() != null) {
doc.setSender(personService.getById(dto.getSenderId()));
}
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
Set<Person> merged = new HashSet<>(doc.getReceivers());
merged.addAll(personService.getAllById(dto.getReceiverIds()));
doc.setReceivers(merged);
}
if (StringUtils.hasText(dto.getDocumentLocation())) {
doc.setDocumentLocation(dto.getDocumentLocation());
}
if (StringUtils.hasText(dto.getArchiveBox())) {
doc.setArchiveBox(dto.getArchiveBox());
}
if (StringUtils.hasText(dto.getArchiveFolder())) {
doc.setArchiveFolder(dto.getArchiveFolder());
}
Document saved = documentRepository.save(doc);
documentVersionService.recordVersion(saved);
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(),
Map.of("source", "BULK_EDIT"));
return saved;
}
/**
@@ -413,17 +540,8 @@ public class DocumentService {
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
Specification<Document> spec = Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops

View File

@@ -109,8 +109,12 @@ public class PersonService {
@Transactional
public Person createPerson(PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
}
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
.personType(dto.getPersonType())
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
@@ -136,9 +140,13 @@ public class PersonService {
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
}
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setPersonType(dto.getPersonType());
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());

View File

@@ -205,6 +205,18 @@ public class TranscriptionService {
return saved;
}
@Transactional
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
for (TranscriptionBlock block : blocks) {
if (!block.isReviewed()) {
block.setReviewed(true);
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
}
}
return blockRepository.saveAll(blocks);
}
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
getBlock(documentId, blockId);
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);

View File

@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -21,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static java.util.stream.Collectors.toSet;
@Service
@RequiredArgsConstructor
@Slf4j
@@ -33,9 +38,10 @@ public class UserService {
private final AppUserRepository userRepository;
private final UserGroupRepository groupRepository;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
@@ -45,10 +51,12 @@ public class UserService {
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
AppUser user;
boolean isNew;
if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getEmail());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
isNew = false;
} else {
log.info("Creating new user: {}", request.getEmail());
user = AppUser.builder()
@@ -61,8 +69,42 @@ public class UserService {
.contact(request.getContact())
.enabled(true)
.build();
isNew = true;
}
AppUser saved = userRepository.save(user);
if (isNew) {
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
}
return saved;
}
@Transactional
public AppUser createUserForBootstrap(CreateUserRequest request) {
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
}
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
if (existingUser.isPresent()) {
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
return userRepository.save(updated);
}
AppUser user = AppUser.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
.firstName(request.getFirstName())
.lastName(request.getLastName())
.birthDate(request.getBirthDate())
.contact(request.getContact())
.enabled(true)
.build();
return userRepository.save(user);
}
@@ -94,10 +136,13 @@ public class UserService {
}
@Transactional
public void deleteUser(UUID userId) {
public void deleteUser(UUID actorId, UUID userId) {
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
String email = user.getEmail();
userRepository.delete(user);
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
Map.of("userId", userId.toString(), "email", email));
}
public AppUser getById(UUID id) {
@@ -141,7 +186,7 @@ public class UserService {
}
@Transactional
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
AppUser user = getById(id);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
@@ -166,13 +211,27 @@ public class UserService {
}
if (dto.getGroupIds() != null) {
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(groups);
Set<UserGroup> before = new HashSet<>(user.getGroups());
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(after);
groupChangePayload(before, after, id, user.getEmail())
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
}
return userRepository.save(user);
}
private Optional<Map<String, Object>> groupChangePayload(
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
if (beforeIds.equals(afterIds)) return Optional.empty();
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
return Optional.of(Map.of("userId", userId.toString(), "email", email,
"addedGroups", added, "removedGroups", removed));
}
@Transactional
public void changePassword(UUID userId, ChangePasswordDTO dto) {
AppUser user = getById(userId);

View File

@@ -6,12 +6,19 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.AppUser;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,4 +54,21 @@ class AuditLogQueryServiceTest {
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
}
@Test
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
when(queryRepository.findByKindIn(anyCollection(), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entry)));
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
assertThat(result).containsExactly(entry);
verify(queryRepository).findByKindIn(
argThat((Collection<AuditKind> kinds) ->
kinds.contains(AuditKind.USER_CREATED) &&
kinds.contains(AuditKind.USER_DELETED) &&
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
any(Pageable.class));
}
}

View File

@@ -0,0 +1,122 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class UserManagementAuditIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired UserService userService;
@Autowired AppUserRepository userRepository;
@Autowired AuditLogRepository auditLogRepository;
@Autowired AuditLogQueryService auditLogQueryService;
@Autowired TransactionTemplate transactionTemplate;
@BeforeEach
void clearAuditLog() {
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
}
@Test
void createAndDeleteUser_producesOrderedAuditEntries() {
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest adminReq = new CreateUserRequest();
adminReq.setEmail("admin@test.example.com");
adminReq.setInitialPassword("admin-secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
UUID actorId = actor.getId();
// Create the target user — should emit USER_CREATED
CreateUserRequest req = new CreateUserRequest();
req.setEmail("audit-test@example.com");
req.setInitialPassword("secret");
transactionTemplate.execute(status -> {
userService.createUserOrUpdate(actorId, req);
return null;
});
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
// Delete the target user — should emit USER_DELETED
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
transactionTemplate.execute(status -> {
userService.deleteUser(actorId, created.getId());
return null;
});
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(2);
assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED);
assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED);
}
@Test
void updateUserGroups_producesGroupMembershipChangedEvent() {
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest actorReq = new CreateUserRequest();
actorReq.setEmail("actor-group-test@test.example.com");
actorReq.setInitialPassword("secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
// Create target user pre-assigned to gA — emits USER_CREATED
CreateUserRequest targetReq = new CreateUserRequest();
targetReq.setEmail("target-group-test@test.example.com");
targetReq.setInitialPassword("secret");
targetReq.setGroupIds(List.of(gA.getId()));
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
// Change groups: Viewers → Editors
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(gB.getId()));
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(1);
AuditLog event = events.get(0);
assertThat(event.getKind()).isEqualTo(AuditKind.GROUP_MEMBERSHIP_CHANGED);
assertThat(event.getPayload()).containsEntry("email", "target-group-test@test.example.com");
@SuppressWarnings("unchecked")
List<String> added = (List<String>) event.getPayload().get("addedGroups");
@SuppressWarnings("unchecked")
List<String> removed = (List<String>) event.getPayload().get("removedGroups");
assertThat(added).containsExactlyInAnyOrder("Editors");
assertThat(removed).containsExactlyInAnyOrder("Viewers");
}
}

View File

@@ -154,6 +154,13 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {

View File

@@ -929,4 +929,315 @@ class DocumentControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
}
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
private static String bulkBody(String... uuids) {
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
for (int i = 0; i < uuids.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(uuids[i]).append("\"");
}
sb.append("]}");
return sb.toString();
}
@Test
void patchBulk_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void patchBulk_returns403_forReadAllUser() throws Exception {
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"documentIds\":[]}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
String[] ids = new String[501];
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
// Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
// Without @Valid on @RequestBody this would silently land an
// arbitrarily long string; the test pins both the annotation and
// the controller-level @Valid wiring.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
String tooLong = "x".repeat(256);
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
String[] ids = new String[500];
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(500));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
.thenAnswer(inv -> Document.builder().id(id).build());
// Same id sent three times — controller should dedupe and call the
// service exactly once, returning updated=1, not 3.
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id.toString(), id.toString(), id.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(1));
verify(documentService, org.mockito.Mockito.times(1))
.applyBulkEditToDocument(eq(id), any(), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id1.toString(), id2.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(2))
.andExpect(jsonPath("$.errors").isEmpty());
verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
}
// ─── GET /api/documents/ids ──────────────────────────────────────────────
@Test
void getDocumentIds_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
// /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
// editieren" fast path; no other consumer needs it.
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(List.of(id));
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0]").value(id.toString()));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_passesSenderIdParamToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID senderId = UUID.randomUUID();
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
.thenReturn(List.of());
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
.andExpect(status().isOk());
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(tooMany);
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
@Test
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[]}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
StringBuilder sb = new StringBuilder("{\"ids\":[");
for (int i = 0; i < 501; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(UUID.randomUUID()).append("\"");
}
sb.append("]}");
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content(sb.toString()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.batchMetadata(any())).thenReturn(List.of(
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + id + "\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Brief"))
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
// Nora C4 — DocumentController.sanitizeForLog defends against
// CWE-117 (log injection) by replacing CR/LF in any free-form string
// it interpolates. Same helper now sanitises BulkEditError.message
// before it round-trips to the frontend.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID badId = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
"evil\r\nFAKE LOG ENTRY: admin logged in"));
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(badId.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.containsString("evil_")));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID okId = UUID.randomUUID();
UUID badId = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
.thenAnswer(inv -> Document.builder().id(okId).build());
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(okId.toString(), badId.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(1))
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
.andExpect(jsonPath("$.errors[0].message").value(
org.hamcrest.Matchers.containsString("not found")));
}
}

View File

@@ -1,6 +1,9 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonNameAlias;
@@ -25,6 +28,7 @@ import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
@@ -183,19 +187,19 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\"}"))
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -204,7 +208,7 @@ class PersonControllerTest {
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\"}"))
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -213,7 +217,7 @@ class PersonControllerTest {
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -225,11 +229,53 @@ class PersonControllerTest {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Hans"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_trimsTitle_beforePersisting() throws Exception {
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.createPerson(captor.capture())).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk());
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
when(personService.createPerson(any())).thenThrow(
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
}
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
@Test
@@ -242,10 +288,10 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -254,7 +300,7 @@ class PersonControllerTest {
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\"}"))
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -267,7 +313,7 @@ class PersonControllerTest {
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Müller"));
}
@@ -317,11 +363,10 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
// firstName valid, lastName blank → second || operand = true → 400
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -339,7 +384,7 @@ class PersonControllerTest {
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"notes\":\"Some notes\"}"))
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
@@ -355,7 +400,7 @@ class PersonControllerTest {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -366,7 +411,7 @@ class PersonControllerTest {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -377,7 +422,7 @@ class PersonControllerTest {
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden());
}
@@ -386,7 +431,7 @@ class PersonControllerTest {
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden());
}

View File

@@ -260,6 +260,13 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteBlock_returns204_whenAuthorised() throws Exception {
@@ -373,4 +380,63 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.reviewed").value(true));
}
// ─── PUT .../review-all ───────────────────────────────────────────────────
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
@Test
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
TranscriptionBlock b1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 1").sortOrder(0).reviewed(true).build();
TranscriptionBlock b2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 2").sortOrder(1).reviewed(true).build();
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of(b1, b2));
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].reviewed").value(true))
.andExpect(jsonPath("$[1].reviewed").value(true));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of());
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -18,8 +18,10 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -104,4 +106,55 @@ class UserControllerTest {
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest());
}
// ─── permission enforcement ───────────────────────────────────────────────
@Test
@WithMockUser(username = "reader@example.com")
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "reader@example.com")
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "reader@example.com")
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
// ─── unauthenticated access ───────────────────────────────────────────────
@Test
void createUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void deleteUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -179,6 +179,22 @@ class DocumentFtsTest {
assertThat(ids).isEmpty();
}
@Test
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
// "Wille" stems to "will" via the German Snowball stemmer.
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
Document doc = documentRepository.saveAndFlush(document("Foto"));
UUID annotationId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
assertThat(ids).contains(doc.getId());
}
@Test
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
documentRepository.saveAndFlush(document("Brief"));

View File

@@ -16,6 +16,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.dto.TagOperator;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
@@ -120,6 +121,23 @@ class DocumentServiceTest {
.isInstanceOf(DomainException.class);
}
@Test
void updateDocument_setsArchiveBoxAndFolder() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setArchiveBox("K-03");
dto.setArchiveFolder("Mappe B");
documentService.updateDocument(id, dto, null, null);
assertThat(doc.getArchiveBox()).isEqualTo("K-03");
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
}
// ─── deleteTagCascading ───────────────────────────────────────────────────
@Test
@@ -1917,4 +1935,333 @@ class DocumentServiceTest {
.isInstanceOf(DomainException.class)
.hasMessageContaining("titles");
}
// ─── applyBulkEditToDocument ─────────────────────────────────────────────
private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() {
return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO();
}
@Test
void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
}
@Test
void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
UUID id = UUID.randomUUID();
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
Document doc = Document.builder().id(id).title("T")
.tags(new HashSet<>(Set.of(existing)))
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tagService.findOrCreate("Kurrent")).thenReturn(added);
var dto = bulkDto();
dto.setTagNames(List.of("Kurrent"));
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
}
@Test
void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
UUID id = UUID.randomUUID();
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
Document doc = Document.builder().id(id).title("T")
.tags(new HashSet<>(Set.of(existing)))
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.applyBulkEditToDocument(id, bulkDto(), null);
assertThat(doc.getTags()).containsExactly(existing);
verify(tagService, never()).findOrCreate(any());
}
@Test
void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
UUID id = UUID.randomUUID();
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
Document doc = Document.builder().id(id).title("T")
.tags(new HashSet<>(Set.of(existing)))
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = bulkDto();
dto.setTagNames(List.of());
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getTags()).containsExactly(existing);
verify(tagService, never()).findOrCreate(any());
}
@Test
void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
UUID id = UUID.randomUUID();
UUID senderId = UUID.randomUUID();
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
Person newSender = Person.builder().id(senderId).firstName("New").build();
Document doc = Document.builder().id(id).title("T")
.sender(oldSender)
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.getById(senderId)).thenReturn(newSender);
var dto = bulkDto();
dto.setSenderId(senderId);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getSender()).isEqualTo(newSender);
}
@Test
void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
UUID id = UUID.randomUUID();
Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
Document doc = Document.builder().id(id).title("T")
.sender(existing)
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.applyBulkEditToDocument(id, bulkDto(), null);
assertThat(doc.getSender()).isEqualTo(existing);
verify(personService, never()).getById(any());
}
@Test
void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
UUID id = UUID.randomUUID();
UUID newReceiverId = UUID.randomUUID();
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
Person added = Person.builder().id(newReceiverId).firstName("New").build();
Document doc = Document.builder().id(id).title("T")
.receivers(new HashSet<>(Set.of(existing)))
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));
var dto = bulkDto();
dto.setReceiverIds(List.of(newReceiverId));
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
}
@Test
void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
UUID id = UUID.randomUUID();
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
Document doc = Document.builder().id(id).title("T")
.receivers(new HashSet<>(Set.of(existing)))
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = bulkDto();
dto.setReceiverIds(List.of());
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getReceivers()).containsExactly(existing);
verify(personService, never()).getAllById(any());
}
@Test
void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() {
UUID id = UUID.randomUUID();
UUID actorId = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
documentService.applyBulkEditToDocument(id, bulkDto(), actorId);
verify(documentVersionService).recordVersion(doc);
verify(auditService).logAfterCommit(
eq(AuditKind.METADATA_UPDATED),
eq(actorId),
eq(id),
eq(java.util.Map.of("source", "BULK_EDIT")));
}
@Test
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T")
.archiveBox("OldBox")
.archiveFolder("OldFolder")
.documentLocation("OldLocation")
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = bulkDto();
dto.setArchiveBox("NewBox");
dto.setArchiveFolder("NewFolder");
dto.setDocumentLocation("NewLocation");
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
}
@Test
void applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable() {
// Sara C1 — unresolvable sender flows up as a per-document error chip
// rather than aborting the controller's batch loop.
UUID id = UUID.randomUUID();
UUID unknownSender = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(personService.getById(unknownSender))
.thenThrow(DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND,
"Person not found: " + unknownSender));
var dto = bulkDto();
dto.setSenderId(unknownSender);
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, dto, null))
.isInstanceOf(DomainException.class)
.hasMessageContaining(unknownSender.toString());
}
// ─── findIdsForFilter ────────────────────────────────────────────────────
@Test
void findIdsForFilter_returnsAllMatchingIds_uncapped() {
Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build();
Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build();
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(d1, d2));
List<UUID> result = documentService.findIdsForFilter(
null, null, null, null, null, null, null, null, null);
assertThat(result).containsExactly(d1.getId(), d2.getId());
}
@Test
void findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec() {
// Sara C3 — tagOp=OR flips useOrLogic at the spec layer; without a
// test pinning this, a refactor that wired OR to AND (or vice versa)
// would slip through.
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of());
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
documentService.findIdsForFilter(
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
// Spec built without throwing → OR branch was exercised. Coverage gain
// is in not-throwing on the OR-specific code path; the actual SQL is
// covered by JPA itself.
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class));
}
@Test
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter(
"xyz", null, null, null, null, null, null, null, null);
assertThat(result).isEmpty();
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
}
// ─── batchMetadata ───────────────────────────────────────────────────────
@Test
void batchMetadata_returnsEmpty_whenIdsIsNull() {
assertThat(documentService.batchMetadata(null)).isEmpty();
}
@Test
void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
assertThat(documentService.batchMetadata(List.of())).isEmpty();
}
@Test
void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
Document d1 = Document.builder().id(id1).title("Brief 1").build();
Document d2 = Document.builder().id(id2).title("Brief 2").build();
when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));
var result = documentService.batchMetadata(List.of(id1, id2));
assertThat(result).hasSize(2);
assertThat(result.get(0).id()).isEqualTo(id1);
assertThat(result.get(0).title()).isEqualTo("Brief 1");
assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
}
@Test
void batchMetadata_silentlyDropsUnknownIds() {
UUID known = UUID.randomUUID();
UUID missing = UUID.randomUUID();
Document d = Document.builder().id(known).title("Found").build();
when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));
var result = documentService.batchMetadata(List.of(known, missing));
assertThat(result).hasSize(1);
assertThat(result.get(0).id()).isEqualTo(known);
}
@Test
void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
UUID id = UUID.randomUUID();
Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));
var result = documentService.batchMetadata(List.of(id));
assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
}
@Test
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T")
.archiveBox("KeepBox")
.archiveFolder("KeepFolder")
.documentLocation("KeepLocation")
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = bulkDto();
dto.setArchiveBox(" ");
dto.setArchiveFolder("");
// documentLocation left null
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
}
}

View File

@@ -114,6 +114,43 @@ class PersonServiceTest {
assertThat(result.getAlias()).isEqualTo("Hans Müller");
}
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
@Test
void createPerson_dto_persistsPersonType() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
Person result = personService.createPerson(dto);
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
}
@Test
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
assertThatThrownBy(() -> personService.createPerson(dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(400);
}
@Test
void createPerson_dto_persistsTitle() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
Person result = personService.createPerson(dto);
assertThat(result.getTitle()).isEqualTo("Prof.");
}
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
@Test
@@ -145,6 +182,36 @@ class PersonServiceTest {
.isEqualTo(400);
}
// ─── updatePerson (personType) ───────────────────────────────────────────
@Test
void updatePerson_throwsInvalidPersonType_whenSkip() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(400);
}
@Test
void updatePerson_persistsPersonType() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
Person result = personService.updatePerson(id, dto);
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
}
// ─── updatePerson (alias) ─────────────────────────────────────────────────
@Test

View File

@@ -506,4 +506,86 @@ class TranscriptionServiceTest {
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
@Test
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(List.of(block1, block2));
}
@Test
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(any());
}
@Test
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(alreadyReviewed, unreviewed));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).isEmpty();
}
}

View File

@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -34,6 +37,7 @@ class UserServiceTest {
@Mock AppUserRepository userRepository;
@Mock UserGroupRepository groupRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock AuditService auditService;
@InjectMocks UserService userService;
// ─── findByEmail ──────────────────────────────────────────────────────────
@@ -61,7 +65,7 @@ class UserServiceTest {
UUID id = UUID.randomUUID();
when(userRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.deleteUser(id))
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
.isInstanceOf(DomainException.class);
}
@@ -71,7 +75,7 @@ class UserServiceTest {
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
userService.deleteUser(id);
userService.deleteUser(UUID.randomUUID(), id);
verify(userRepository).delete(user);
}
@@ -90,7 +94,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
assertThat(result).isEqualTo(saved);
verify(userRepository).save(any());
@@ -108,7 +112,7 @@ class UserServiceTest {
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(userRepository, times(1)).save(existing);
}
@@ -229,7 +233,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getFirstName()).isEqualTo("Ada");
assertThat(result.getLastName()).isEqualTo("Lovelace");
@@ -246,7 +250,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getGroups()).containsExactly(adminGroup);
}
@@ -264,7 +268,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(newGroup.getId()));
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getGroups()).containsExactly(newGroup);
}
@@ -281,7 +285,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of());
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getGroups()).isEmpty();
}
@@ -313,7 +317,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
assertThat(result).isEqualTo(saved);
verify(groupRepository).findAllById(List.of(group.getId()));
@@ -378,7 +382,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword("newSecret");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getPassword()).isEqualTo("newHashed");
}
@@ -393,7 +397,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword(" ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getPassword()).isEqualTo("original");
verify(passwordEncoder, never()).encode(any());
@@ -408,7 +412,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(" ");
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("blank");
}
@@ -425,7 +429,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("taken@example.com");
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("E-Mail");
}
@@ -497,7 +501,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(groupRepository, never()).findAllById(any());
}
@@ -561,7 +565,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(null);
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isNull();
}
@@ -576,7 +580,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isNull();
}
@@ -591,7 +595,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" phone: 555 ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isEqualTo("phone: 555");
}
@@ -606,7 +610,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(null);
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getEmail()).isEqualTo("keep@example.com");
}
@@ -622,7 +626,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("me@example.com");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getEmail()).isEqualTo("me@example.com");
}
@@ -640,7 +644,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(groupRepository, never()).findAllById(any());
}
@@ -699,6 +703,160 @@ class UserServiceTest {
assertThat(result).containsExactly(g);
}
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
@Test
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(newGroup.getId()));
userService.adminUpdateUser(actorId, userId, dto);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
java.util.Map<String, Object> payload = payloadCaptor.getValue();
assertThat(payload).containsEntry("email", "u@example.com");
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
}
@Test
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(group.getId()));
userService.adminUpdateUser(actorId, userId, dto);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
@Test
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
// groupIds not set → null
userService.adminUpdateUser(actorId, userId, dto);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
@Test
void deleteUser_logsUserDeleted_withEmailInPayload() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
userService.deleteUser(actorId, userId);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
assertThat(payloadCaptor.getValue()).containsKey("userId");
}
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
@Test
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
UUID actorId = UUID.randomUUID();
CreateUserRequest req = new CreateUserRequest();
req.setEmail("new@example.com");
req.setInitialPassword("secret");
req.setGroupIds(List.of());
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(actorId, req);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
assertThat(payloadCaptor.getValue()).containsKey("userId");
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
}
@Test
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
UUID actorId = UUID.randomUUID();
CreateUserRequest req = new CreateUserRequest();
req.setEmail("existing@example.com");
req.setInitialPassword("pass");
req.setGroupIds(List.of());
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(actorId, req);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── createUserForBootstrap ───────────────────────────────────────────────
@Test
void createUserForBootstrap_createsUserWithoutAuditEvent() {
CreateUserRequest req = new CreateUserRequest();
req.setEmail("bootstrap@example.com");
req.setInitialPassword("secret");
req.setGroupIds(List.of());
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserForBootstrap(req);
assertThat(result).isEqualTo(saved);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── createGroup ──────────────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
/**
* E2E coverage for the bulk metadata edit feature (issue #225).
*
* Assumptions:
* - Auth setup has run as the admin user (WRITE_ALL).
* - The backend exposes /api/documents/{bulk,batch-metadata,ids}.
* - At least two documents exist in the search list at /documents.
*/
test.describe('Bulk metadata edit', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/documents');
await page.waitForSelector('[data-hydrated]');
});
test('checking two documents shows the sticky selection bar with the count', async ({ page }) => {
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
await expect(checkboxes.first()).toBeVisible();
await checkboxes.nth(0).check();
await checkboxes.nth(1).check();
const bar = page.getByTestId('bulk-selection-bar');
await expect(bar).toBeVisible();
await expect(page.getByTestId('bulk-selection-count')).toContainText('2');
});
test('Alles aufheben hides the bar', async ({ page }) => {
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
await checkboxes.nth(0).check();
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
await page.getByTestId('bulk-clear-all').click();
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
});
test('Massenbearbeitung navigates to bulk-edit with the selected documents', async ({ page }) => {
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
await checkboxes.nth(0).check();
await checkboxes.nth(1).check();
await page.getByTestId('bulk-edit-open').click();
await page.waitForURL('**/documents/bulk-edit');
// Onboarding callout is the surest indicator the edit-mode layout rendered.
await expect(page.getByTestId('bulk-edit-callout')).toBeVisible();
});
test('navigating to /documents/bulk-edit with no selection redirects back to /documents', async ({
page
}) => {
// Navigate directly without checking anything first.
await page.goto('/documents/bulk-edit');
await page.waitForURL('**/documents');
expect(page.url()).toMatch(/\/documents(\?|$)/);
});
test('the same selection bar drives the /enrich page', async ({ page }) => {
await page.goto('/enrich');
await page.waitForSelector('[data-hydrated]');
// /enrich may legitimately be empty if every doc has metadata. In that
// case there's nothing to bulk-select; skip.
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
const count = await checkboxes.count();
test.skip(count === 0, 'No incomplete documents available on /enrich');
await checkboxes.first().check();
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
await expect(page.getByTestId('bulk-selection-count')).toContainText('1');
await page.getByTestId('bulk-clear-all').click();
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
});
});

View File

@@ -8,21 +8,27 @@ test.describe('Help chip — Read/Edit panel header', () => {
docId = await createEmptyDocument(request);
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
// Find and click the (?) help chip
const helpBtn = page.locator('button[aria-expanded]');
// Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
await expect(helpBtn).toBeVisible({ timeout: 5000 });
await helpBtn.click();
// Popover should open
await expect(page.locator('[role="tooltip"]')).toBeVisible();
// Popover should open (role="region", not tooltip — click-triggered panels are regions)
await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
// Press Esc
await page.keyboard.press('Escape');
await expect(page.locator('[role="tooltip"]')).not.toBeVisible();
await expect(
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
).not.toBeVisible();
// Focus should have returned to the chip
await expect(helpBtn).toBeFocused();

View File

@@ -1,10 +1,30 @@
import type { APIRequestContext } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, '../fixtures/minimal.pdf');
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
const res = await request.post('/api/documents', {
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Transcribe Coach Test' }
});
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
const doc = await res.json();
return doc.id as string;
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json();
const docId = doc.id as string;
const uploadRes = await request.put(`/api/documents/${docId}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
return docId;
}

View File

@@ -63,6 +63,12 @@ test.describe('Richtlinien page — print media', () => {
await expect(nav).toBeHidden();
}
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
// text does not clutter the printed output (the print CSS declares display:none)
for (const span of await page.locator('.new-tab').all()) {
await expect(span).toBeHidden();
}
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
});
});

View File

@@ -2,6 +2,24 @@ import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { createEmptyDocument } from './helpers/upload-empty-document.js';
async function createBlock(
request: Parameters<typeof createEmptyDocument>[0],
docId: string
): Promise<void> {
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
data: {
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.1,
text: 'Liebe Mutter,',
label: null
}
});
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
}
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
}
@@ -13,10 +31,13 @@ test.describe('Transcribe coach — empty state', () => {
docId = await createEmptyDocument(request);
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
page
}) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
@@ -31,14 +52,12 @@ test.describe('Transcribe coach — empty state', () => {
});
test('training footer is NOT visible on empty doc', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
});
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
@@ -50,10 +69,9 @@ test.describe('Transcribe coach — empty state', () => {
});
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`);
// Toggle dark theme
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
await page.getByRole('button', { name: /dark mode/i }).click();
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
timeout: 5000
@@ -63,3 +81,25 @@ test.describe('Transcribe coach — empty state', () => {
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
});
});
test.describe('Transcribe coach — with blocks', () => {
let docId: string;
test.beforeAll(async ({ request }) => {
docId = await createEmptyDocument(request);
await createBlock(request, docId);
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('training footer IS visible when at least one block exists', async ({ page }) => {
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
await page.locator('[data-testid="mode-edit"]').click();
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
});
});

View File

@@ -40,6 +40,26 @@ export default defineConfig(
parser: ts.parser,
svelteConfig
}
},
rules: {
// text-accent resolves to #a1dcd8 in light mode (1.52:1 on white — WCAG fail).
// layout.css documents it as decorative-only (borders, icon tints, bg fills).
// For any text label use text-primary or text-ink instead. This rule catches
// the pattern where text-accent appears inside a JavaScript string literal
// (e.g. conditional ternary class expressions in Svelte templates).
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/\\btext-accent\\b/]',
message:
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
},
{
selector: 'TemplateLiteral > TemplateElement[value.raw=/\\btext-accent\\b/]',
message:
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
}
]
}
}
);

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Briefwechsel",
"nav_admin": "Admin",
"nav_logout": "Abmelden",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"btn_save": "Speichern",
"btn_cancel": "Abbrechen",
"btn_confirm": "Bestätigen",
@@ -33,6 +35,8 @@
"btn_back_to_overview": "Zurück zur Übersicht",
"btn_back": "Zurück",
"btn_back_to_document": "Zurück zum Dokument",
"form_label_person_type": "Typ",
"form_label_name": "Name",
"form_label_first_name": "Vorname",
"form_label_last_name": "Nachname",
"form_label_alias": "Rufname / Alias",
@@ -515,7 +519,6 @@
"scan_collapse": "Scan verkleinern",
"transcription_empty_title": "Noch keine Transkription",
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
"transcription_panel_close": "Panel schließen",
"person_alias_heading": "Namensverlauf",
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
@@ -528,6 +531,7 @@
"person_type_INSTITUTION": "Institution",
"person_type_GROUP": "Gruppe",
"person_type_UNKNOWN": "Unbekannt",
"a11y_type_changed": "Typ geändert zu {type}",
"person_alias_add_heading": "Name hinzufuegen",
"person_alias_label_type": "Art",
"person_alias_label_last_name": "Nachname",
@@ -537,6 +541,9 @@
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
"person_alias_btn_delete": "Entfernen",
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
"validation_last_name_required": "Nachname ist Pflichtfeld.",
"validation_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
@@ -811,6 +818,7 @@
"pagination_next": "Weiter",
"pagination_page_of": "Seite {page} von {total}",
"pagination_nav_label": "Seitennavigation",
"pagination_page_button": "Seite {page}",
"common_opens_new_tab": "(öffnet in neuem Tab)",
@@ -828,9 +836,9 @@
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
"richtlinien_title": "Transkriptions-Richtlinien",
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
"richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Regeln für die Transkription",
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
@@ -874,5 +882,30 @@
"bulk_drop_zone_label": "Dateien ablegen",
"bulk_remove_file": "Entfernen",
"bulk_title_single": "Neues Dokument",
"bulk_title_multi": "Neue Dokumente"
"bulk_title_multi": "Neue Dokumente",
"bulk_edit_button": "Massenbearbeitung",
"bulk_edit_n_selected_one": "1 Dokument ausgewählt",
"bulk_edit_n_selected_other": "{count} Dokumente ausgewählt",
"bulk_edit_clear_all": "Alles aufheben",
"bulk_edit_all_x": "Alle {count} editieren",
"bulk_edit_select_document": "Dokument {title} auswählen",
"bulk_edit_hint": "Nur ausgefüllte Felder werden angewendet. Tags und Empfänger werden hinzugefügt, nicht ersetzt.",
"bulk_edit_badge_additive": "+ wird hinzugefügt",
"bulk_edit_badge_replace": "wird ersetzt",
"bulk_edit_save_progress": "Batch {done} von {total} verarbeitet",
"bulk_edit_save_partial": "{done} von {total} gespeichert",
"bulk_edit_retry": "Erneut versuchen",
"bulk_edit_title": "Massenbearbeitung",
"bulk_edit_save_button": "Anwenden",
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage.",
"form_label_archive_box": "Karton",
"form_helper_archive_box": "Welcher Karton im Archiv?",
"form_label_archive_folder": "Mappe",
"form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?",
"bulk_edit_clear_selection": "Auswahl aufheben",
"bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben",
"bulk_edit_loading": "Dokumente werden geladen…",
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
"bulk_edit_topbar_title": "Massenbearbeitung",
"bulk_edit_count_pill": "{count} werden bearbeitet"
}

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Letters",
"nav_admin": "Admin",
"nav_logout": "Sign out",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"btn_save": "Save",
"btn_cancel": "Cancel",
"btn_confirm": "Confirm",
@@ -33,6 +35,8 @@
"btn_back_to_overview": "Back to overview",
"btn_back": "Back",
"btn_back_to_document": "Back to document",
"form_label_person_type": "Type",
"form_label_name": "Name",
"form_label_first_name": "First name",
"form_label_last_name": "Last name",
"form_label_alias": "Nickname / Alias",
@@ -515,7 +519,6 @@
"scan_collapse": "Collapse scan",
"transcription_empty_title": "No transcription yet",
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
"transcription_panel_close": "Close panel",
"person_alias_heading": "Name history",
"person_alias_empty": "No name changes recorded yet.",
@@ -528,6 +531,7 @@
"person_type_INSTITUTION": "Institution",
"person_type_GROUP": "Group",
"person_type_UNKNOWN": "Unknown",
"a11y_type_changed": "Type changed to {type}",
"person_alias_add_heading": "Add name",
"person_alias_label_type": "Type",
"person_alias_label_last_name": "Last name",
@@ -537,6 +541,9 @@
"person_alias_delete_body": "This name will be removed from search results.",
"person_alias_btn_delete": "Remove",
"error_alias_not_found": "The name alias was not found.",
"error_invalid_person_type": "The specified person type is not valid.",
"validation_last_name_required": "Last name is required.",
"validation_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.",
"error_ocr_job_not_found": "The OCR job was not found.",
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
@@ -811,6 +818,7 @@
"pagination_next": "Next",
"pagination_page_of": "Page {page} of {total}",
"pagination_nav_label": "Pagination",
"pagination_page_button": "Page {page}",
"common_opens_new_tab": "(opens in new tab)",
@@ -828,9 +836,9 @@
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
"richtlinien_title": "Transcription Guidelines",
"richtlinien_intro": "So every letter is transcribed consistently — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
"richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Transcription rules",
"richtlinien_rule_unleserlich_title": "Illegible words",
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
@@ -874,5 +882,30 @@
"bulk_drop_zone_label": "Drop files here",
"bulk_remove_file": "Remove",
"bulk_title_single": "New Document",
"bulk_title_multi": "New Documents"
"bulk_title_multi": "New Documents",
"bulk_edit_button": "Bulk edit",
"bulk_edit_n_selected_one": "1 document selected",
"bulk_edit_n_selected_other": "{count} documents selected",
"bulk_edit_clear_all": "Clear all",
"bulk_edit_all_x": "Edit all {count}",
"bulk_edit_select_document": "Select document {title}",
"bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.",
"bulk_edit_badge_additive": "+ will be added",
"bulk_edit_badge_replace": "will replace",
"bulk_edit_save_progress": "Batch {done} of {total} processed",
"bulk_edit_save_partial": "{done} of {total} saved",
"bulk_edit_retry": "Retry",
"bulk_edit_title": "Bulk edit",
"bulk_edit_save_button": "Apply",
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request.",
"form_label_archive_box": "Box",
"form_helper_archive_box": "Which box in the archive?",
"form_label_archive_folder": "Folder",
"form_helper_archive_folder": "Which folder inside the box?",
"bulk_edit_clear_selection": "Clear selection",
"bulk_edit_clear_hint_keyboard": "Esc: clear selection",
"bulk_edit_loading": "Loading documents…",
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
"bulk_edit_topbar_title": "Bulk edit",
"bulk_edit_count_pill": "{count} will be edited"
}

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Cartas",
"nav_admin": "Admin",
"nav_logout": "Cerrar sesión",
"theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro",
"btn_save": "Guardar",
"btn_cancel": "Cancelar",
"btn_confirm": "Confirmar",
@@ -33,6 +35,8 @@
"btn_back_to_overview": "Volver al resumen",
"btn_back": "Volver",
"btn_back_to_document": "Volver al documento",
"form_label_person_type": "Tipo",
"form_label_name": "Nombre",
"form_label_first_name": "Nombre",
"form_label_last_name": "Apellido",
"form_label_alias": "Apodo / Alias",
@@ -515,7 +519,6 @@
"scan_collapse": "Reducir escaneo",
"transcription_empty_title": "Sin transcripcion",
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
"transcription_panel_close": "Cerrar panel",
"person_alias_heading": "Historial de nombres",
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
@@ -528,6 +531,7 @@
"person_type_INSTITUTION": "Institución",
"person_type_GROUP": "Grupo",
"person_type_UNKNOWN": "Desconocido",
"a11y_type_changed": "Tipo cambiado a {type}",
"person_alias_add_heading": "Agregar nombre",
"person_alias_label_type": "Tipo",
"person_alias_label_last_name": "Apellido",
@@ -537,6 +541,9 @@
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
"person_alias_btn_delete": "Eliminar",
"error_alias_not_found": "No se encontro el alias de nombre.",
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
"validation_last_name_required": "El apellido es obligatorio.",
"validation_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
@@ -811,6 +818,7 @@
"pagination_next": "Siguiente",
"pagination_page_of": "Página {page} de {total}",
"pagination_nav_label": "Paginación",
"pagination_page_button": "Página {page}",
"common_opens_new_tab": "(abre en pestaña nueva)",
@@ -828,9 +836,9 @@
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
"richtlinien_title": "Normas de transcripción",
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.",
"richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Reglas de transcripción",
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
@@ -874,5 +882,30 @@
"bulk_drop_zone_label": "Soltar archivos aquí",
"bulk_remove_file": "Eliminar",
"bulk_title_single": "Nuevo Documento",
"bulk_title_multi": "Nuevos Documentos"
"bulk_title_multi": "Nuevos Documentos",
"bulk_edit_button": "Edición masiva",
"bulk_edit_n_selected_one": "1 documento seleccionado",
"bulk_edit_n_selected_other": "{count} documentos seleccionados",
"bulk_edit_clear_all": "Limpiar todo",
"bulk_edit_all_x": "Editar los {count}",
"bulk_edit_select_document": "Seleccionar documento {title}",
"bulk_edit_hint": "Solo se aplican los campos rellenados. Las etiquetas y los destinatarios se añaden, no se reemplazan.",
"bulk_edit_badge_additive": "+ se añade",
"bulk_edit_badge_replace": "se reemplaza",
"bulk_edit_save_progress": "Lote {done} de {total} procesado",
"bulk_edit_save_partial": "{done} de {total} guardado",
"bulk_edit_retry": "Reintentar",
"bulk_edit_title": "Edición masiva",
"bulk_edit_save_button": "Aplicar",
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud.",
"form_label_archive_box": "Caja",
"form_helper_archive_box": "¿Qué caja del archivo?",
"form_label_archive_folder": "Carpeta",
"form_helper_archive_folder": "¿Qué carpeta dentro de la caja?",
"bulk_edit_clear_selection": "Limpiar selección",
"bulk_edit_clear_hint_keyboard": "Esc: limpiar selección",
"bulk_edit_loading": "Cargando documentos…",
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
"bulk_edit_topbar_title": "Edición masiva",
"bulk_edit_count_pill": "Se editarán {count}"
}

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, afterEach } from 'vitest';
const { radioGroupNav } = await import('./radioGroupNav');
describe('radioGroupNav action', () => {
const nodes: HTMLElement[] = [];
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
const container = document.createElement('div');
container.setAttribute('role', 'radiogroup');
const buttons: HTMLElement[] = [];
for (let i = 0; i < count; i++) {
const btn = document.createElement('button');
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
container.appendChild(btn);
buttons.push(btn);
}
document.body.appendChild(container);
nodes.push(container);
return { container, buttons };
}
afterEach(() => {
nodes.forEach((n) => n.remove());
nodes.length = 0;
});
it('ArrowRight moves focus to next button', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('ArrowRight wraps from last to first', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[3].focus();
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('ArrowLeft moves focus to previous button', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[2].focus();
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('ArrowLeft wraps from first to last', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(buttons[3]);
});
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
});
it('destroy removes keydown listener', () => {
const { container, buttons } = makeGroup(4);
const { destroy } = radioGroupNav(container);
destroy();
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('ignores non-arrow keys', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
});

View File

@@ -0,0 +1,37 @@
export function radioGroupNav(
node: HTMLElement,
onChange?: (value: string) => void
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
let onChangeFn = onChange;
function getRadios(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
const radios = getRadios();
const current = radios.indexOf(document.activeElement as HTMLElement);
if (current === -1) return;
const delta = event.key === 'ArrowRight' ? 1 : -1;
const next = (current + delta + radios.length) % radios.length;
radios[current].setAttribute('aria-checked', 'false');
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();
onChangeFn?.(radios[next].getAttribute('value') ?? '');
}
node.addEventListener('keydown', handleKeydown);
return {
update(newOnChange) {
onChangeFn = newOnChange;
},
destroy() {
node.removeEventListener('keydown', handleKeydown);
}
};
}

View File

@@ -18,7 +18,8 @@ let {
dimmed = false,
flashAnnotationId = null,
onDraw,
onAnnotationClick
onAnnotationClick,
onDeleteRequest
}: {
annotations: Annotation[];
canDraw: boolean;
@@ -29,6 +30,7 @@ let {
flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void;
onDeleteRequest?: (annotationId: string) => void;
} = $props();
let drawStart = $state<{ x: number; y: number } | null>(null);
@@ -112,6 +114,8 @@ const containerStyle = $derived(
dimmed={dimmed}
blockNumber={blockNumbers[annotation.id]}
isFlashing={flashAnnotationId === annotation.id}
showDelete={canDraw}
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
onclick={() => onAnnotationClick?.(annotation.id)}
onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (hoveredId = null)}

View File

@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
expect(el2.style.opacity).toBe('1');
});
it('does not show delete buttons (annotations owned by blocks)', async () => {
it('does not show delete button when annotation is not hovered or active', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: true,
@@ -107,6 +107,19 @@ describe('AnnotationLayer', () => {
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when canDraw is false even if annotation is active', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: false,
color: '#00C7B1',
activeAnnotationId: 'ann-1',
onDraw: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
});

View File

@@ -11,6 +11,8 @@ let {
blockNumber = undefined,
isFlashing = false,
isResizable = false,
showDelete = false,
onDeleteRequest,
onclick,
onpointerenter,
onpointerleave
@@ -23,11 +25,15 @@ let {
blockNumber?: number | undefined;
isFlashing?: boolean;
isResizable?: boolean;
showDelete?: boolean;
onDeleteRequest?: () => void;
onclick: () => void;
onpointerenter: () => void;
onpointerleave: () => void;
} = $props();
const deleteVisible = $derived(showDelete && (isHovered || isActive));
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
@@ -83,6 +89,7 @@ let shapeStyle = $derived(
onclick={onclick}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick();
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
}}
onpointerenter={onpointerenter}
onpointerleave={onpointerleave}
@@ -112,6 +119,51 @@ let shapeStyle = $derived(
{blockNumber}
</div>
{/if}
{#if deleteVisible}
<button
data-testid="annotation-delete-{annotation.id}"
type="button"
aria-label="Löschen"
onclick={(e) => {
e.stopPropagation();
onDeleteRequest?.();
}}
style="
position: absolute;
top: 4px;
right: 4px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #fff;
border: 1px solid var(--color-error, #e53e3e);
color: var(--color-error, #e53e3e);
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
z-index: 10;
"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{#if isResizable}
<AnnotationEditOverlay annotation={annotation} />
{/if}

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationShape from './AnnotationShape.svelte';
afterEach(cleanup);
function makeAnnotation(id = 'ann-1') {
return {
id,
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.2,
color: '#00C7B1',
createdAt: new Date().toISOString()
};
}
describe('AnnotationShape', () => {
it('renders the annotation element', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
});
it('does not show delete button when showDelete is false', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: false,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('shows delete button when showDelete is true and isHovered is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('shows delete button when showDelete is true and isActive is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('calls onDeleteRequest when delete button is clicked', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onclick when delete button is clicked', async () => {
const onclick = vi.fn();
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick,
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onclick).not.toHaveBeenCalled();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: false,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).not.toHaveBeenCalled();
});
});

View File

@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
import { applyOffsets } from '$lib/search';
import { formatDate } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import ProgressRing from './ProgressRing.svelte';
import ContributorStack from './ContributorStack.svelte';
import DocumentThumbnail from './DocumentThumbnail.svelte';
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
let { item }: { item: DocumentSearchItem } = $props();
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
const doc = $derived(item.document);
const titleText = $derived(doc.title || doc.originalFilename);
@@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
<div class="flex gap-3 sm:gap-5">
<!-- Bulk-selection checkbox -->
{#if canWrite}
<label
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
data-testid="bulk-select-checkbox"
>
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-brand-navy"
checked={bulkSelectionStore.has(doc.id)}
onchange={() => bulkSelectionStore.toggle(doc.id)}
aria-label={m.bulk_edit_select_document({ title: titleText })}
/>
</label>
{/if}
<!-- Thumbnail tile -->
<DocumentThumbnail doc={doc} size="lg" />

View File

@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import DocumentRow from './DocumentRow.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import type { components } from '$lib/generated/api';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.mocked(goto).mockClear();
bulkSelectionStore.clear();
});
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
@@ -265,6 +267,45 @@ describe('DocumentRow tags', () => {
});
});
// ─── Bulk-selection checkbox ─────────────────────────────────────────────────
describe('DocumentRow bulk selection checkbox', () => {
it('does not render the checkbox when canWrite is false', async () => {
render(DocumentRow, { item: makeItem(), canWrite: false });
await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument();
});
it('renders the checkbox when canWrite is true', async () => {
render(DocumentRow, { item: makeItem(), canWrite: true });
await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument();
});
it('checkbox aria-label includes the document title', async () => {
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
render(DocumentRow, { item, canWrite: true });
await expect
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
.toBeInTheDocument();
});
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
render(DocumentRow, { item, canWrite: true });
expect(bulkSelectionStore.has('doc-42')).toBe(false);
document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.click();
await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true);
});
it('checked state mirrors the store', async () => {
bulkSelectionStore.add('doc-99');
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
render(DocumentRow, { item, canWrite: true });
await expect.element(page.getByRole('checkbox')).toBeChecked();
});
});
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
describe('DocumentRow progress ring and contributors', () => {

View File

@@ -24,6 +24,7 @@ type Props = {
flashAnnotationId?: string | null;
onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
};
let {
@@ -38,7 +39,8 @@ let {
annotationsDimmed = false,
flashAnnotationId = null,
onAnnotationClick,
onTranscriptionDraw
onTranscriptionDraw,
onDeleteAnnotationRequest
}: Props = $props();
</script>
@@ -98,6 +100,7 @@ let {
flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}

View File

@@ -1,3 +1,10 @@
<script module>
// Module-level counter produces stable, predictable IDs across SSR + hydration.
// Math.random() would generate different values server-side vs client-side,
// causing a hydration mismatch on first render.
let _counter = 0;
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
@@ -11,8 +18,9 @@ type Props = {
let { label, placement = 'bottom', children }: Props = $props();
const popoverId = `help-popover-${_counter++}`;
let open = $state(false);
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
let triggerEl: HTMLButtonElement | null = $state(null);
function toggle() {
@@ -58,6 +66,10 @@ const placementClass: Record<Placement, string> = {
</script>
<div class="relative inline-block">
<!--
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
audience is 60+). The inner <span> carries the visual 20×20px circle.
-->
<button
bind:this={triggerEl}
type="button"
@@ -65,15 +77,20 @@ const placementClass: Record<Placement, string> = {
aria-expanded={open}
aria-controls={popoverId}
onclick={toggle}
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy"
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
>
?
<span
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors group-hover:border-brand-navy group-hover:text-brand-navy"
>
?
</span>
</button>
{#if open}
<div
id={popoverId}
role="tooltip"
role="region"
aria-label={label}
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
>
{#if children}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { page, userEvent } from 'vitest/browser';
import HelpPopover from './HelpPopover.svelte';
afterEach(cleanup);
@@ -20,7 +20,7 @@ describe('HelpPopover — initial state', () => {
renderPopover();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('[role="tooltip"]')).toBeNull();
expect(document.querySelector('[role="region"]')).toBeNull();
});
});
@@ -30,37 +30,61 @@ describe('HelpPopover — open / close interactions', () => {
await page.getByRole('button', { name: /Help/ }).click();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
expect(document.querySelector('[role="region"]')).not.toBeNull();
});
it('closes on Esc key', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
});
it('closes on outside click', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
});
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => {
it('opens on Enter key', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
await userEvent.keyboard('{Enter}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => {
it('opens on Space key', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
await userEvent.keyboard('{Space}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
});
describe('HelpPopover — hover-target', () => {
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
const { container } = renderPopover();
const btn = container.querySelector('button[aria-expanded]')!;
const span = btn.querySelector('span')!;
const btnClasses = btn.className.split(/\s+/);
const spanClasses = span.className.split(/\s+/);
expect(btnClasses).toContain('group');
expect(spanClasses).not.toContain('hover:border-brand-navy');
expect(spanClasses).toContain('group-hover:border-brand-navy');
expect(spanClasses).not.toContain('hover:text-brand-navy');
expect(spanClasses).toContain('group-hover:text-brand-navy');
});
it('outer button has focus-visible ring for keyboard users', () => {
const { container } = renderPopover();
const btn = container.querySelector('button[aria-expanded]')!;
expect(btn.className).toContain('focus-visible:ring-2');
expect(btn.className).toContain('focus-visible:ring-brand-navy');
});
});
@@ -74,4 +98,17 @@ describe('HelpPopover — aria wiring', () => {
const popover = document.getElementById(controls!);
expect(popover).not.toBeNull();
});
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
expect(id1).toBeTruthy();
expect(id2).toBeTruthy();
expect(id1).not.toBe(id2);
// IDs must be deterministic (counter-based), not random hex
expect(id1).toMatch(/^help-popover-\d+$/);
expect(id2).toMatch(/^help-popover-\d+$/);
});
});

View File

@@ -48,6 +48,12 @@ function handleKeydown(event: KeyboardEvent) {
}
}
const bellLabel = $derived(
stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()
);
function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node;
return () => {
@@ -72,12 +78,11 @@ onDestroy(() => {
{@attach attachBellButton}
type="button"
onclick={toggleDropdown}
aria-label={stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()}
aria-label={bellLabel}
title={bellLabel}
aria-expanded={open}
aria-haspopup="true"
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.classList.contains('cursor-pointer')).toBe(true);
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = [];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
it('bell button title equals aria-label when unreadCount is 3', async () => {
mockNotificationList.value = [
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }),
makeNotification({ id: 'n3' })
];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
});
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];

View File

@@ -20,6 +20,48 @@ const controlBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
const activePageBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white';
/**
* Builds the sliding window of 1-indexed page numbers to render as buttons.
* Always shows: first, last, current, one neighbor each side.
* null entries represent ellipsis gaps.
*/
const pageWindow = $derived.by(() => {
const first = 1;
const last = totalPages;
const current = page + 1; // convert to 1-indexed
const windowStart = Math.max(first, current - 1);
const windowEnd = Math.min(last, current + 1);
const result: (number | null)[] = [];
result.push(first);
if (windowStart > first + 2) {
result.push(null); // left ellipsis
} else if (windowStart === first + 2) {
result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis
}
for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) {
result.push(p);
}
if (windowEnd < last - 2) {
result.push(null); // right ellipsis
} else if (windowEnd === last - 2) {
result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis
}
if (last > first) {
result.push(last);
}
return result;
});
</script>
{#if totalPages > 1}
@@ -52,13 +94,60 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
</span>
{/if}
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
<span
data-testid="pagination-page-label"
aria-current="page"
class="font-sans text-sm text-ink-2"
aria-hidden="true"
class="font-sans text-sm text-ink-2 sm:hidden"
>
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Always in the AT tree: announces current page regardless of breakpoint.
On mobile, the desktop button container is display:none so this is the only AT anchor.
On desktop, the active page button also carries aria-current — both announce the same info. -->
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Desktop: numbered page buttons (hidden below sm:) -->
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
{#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
{#if entry === null}
{#if i === 1}
<span
data-testid="pagination-ellipsis-left"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{:else}
<span
data-testid="pagination-ellipsis-right"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{/if}
{:else if entry === page + 1}
<span
data-testid="pagination-page-{entry}"
aria-current="page"
aria-label={m.pagination_page_button({ page: entry })}
class={activePageBase}
>
{entry}
</span>
{:else}
<a
data-testid="pagination-page-{entry}"
aria-label={m.pagination_page_button({ page: entry })}
href={makeHref(entry - 1)}
class={linkBase}
>
{entry}
</a>
{/if}
{/each}
</div>
{#if hasNext}
<a

View File

@@ -19,11 +19,145 @@ describe('Pagination', () => {
await expect.element(label).toHaveTextContent(/10/);
});
it('marks the current page label with aria-current="page"', async () => {
it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-current', 'page');
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
});
describe('page number buttons', () => {
it('renders page number buttons when totalPages > 1', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
// active page button — the current page (5, 1-indexed)
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toBeInTheDocument();
});
it('does not render page number buttons when totalPages <= 1', async () => {
render(Pagination, { page: 0, totalPages: 1, makeHref });
// entire nav is hidden
const nav = page.getByRole('navigation');
await expect.element(nav).not.toBeInTheDocument();
});
it('marks the active page button with aria-current="page"', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveAttribute('aria-current', 'page');
});
it('active page button has brand-navy background', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveClass(/bg-brand-navy/);
});
it('active page button has 44px touch target', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/);
await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/);
});
it('inactive page buttons link to their target page via makeHref', async () => {
const spy = vi.fn(makeHref);
render(Pagination, { page: 4, totalPages: 12, makeHref: spy });
const nav = page.getByRole('navigation');
// page button for page 1 (0-indexed: 0) should link to /documents?page=0
const firstPageBtn = nav.getByTestId('pagination-page-1');
await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0');
});
it('renders first and last page buttons always visible', async () => {
render(Pagination, { page: 5, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument();
});
it('renders ellipsis span between first page and window when gap exists', async () => {
// page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5
render(Pagination, { page: 5, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const ellipses = nav.getByTestId('pagination-ellipsis-left');
await expect.element(ellipses).toBeInTheDocument();
});
it('renders ellipsis span between window and last page when gap exists', async () => {
// page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12
render(Pagination, { page: 0, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const ellipsis = nav.getByTestId('pagination-ellipsis-right');
await expect.element(ellipsis).toBeInTheDocument();
});
it('does not render left ellipsis when window is adjacent to first page', async () => {
// page 1 (0-indexed: 0) — window starts at 1, adjacent to first page
render(Pagination, { page: 0, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const leftEllipsis = nav.getByTestId('pagination-ellipsis-left');
await expect.element(leftEllipsis).not.toBeInTheDocument();
});
it('does not render right ellipsis when window is adjacent to last page', async () => {
// last page (0-indexed: 11) — window ends at 12, adjacent to last page
render(Pagination, { page: 11, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const rightEllipsis = nav.getByTestId('pagination-ellipsis-right');
await expect.element(rightEllipsis).not.toBeInTheDocument();
});
it('page buttons container has hidden class on mobile (sm: prefix)', async () => {
// The page buttons container must be hidden below sm: breakpoint
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const pageButtons = nav.getByTestId('pagination-pages');
await expect.element(pageButtons).toHaveClass(/hidden/);
await expect.element(pageButtons).toHaveClass(/sm:flex/);
});
it('renders both pages without ellipsis when totalPages is 2', async () => {
render(Pagination, { page: 0, totalPages: 2, makeHref });
const nav = page.getByRole('navigation');
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument();
});
});
it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
});
it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const nav = page.getByRole('navigation');
const srLabel = nav.getByTestId('pagination-current-page-sr');
await expect.element(srLabel).toBeInTheDocument();
await expect.element(srLabel).toHaveAttribute('aria-current', 'page');
});
it('renders prev as a link pointing at page - 1 when not on first page', async () => {

View File

@@ -91,7 +91,7 @@ let {
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
? 'text-ink-2 hover:bg-surface/10'
: 'bg-surface/10 text-accent'}"
: 'bg-surface/10 text-primary'}"
>
<svg
class="h-3.5 w-3.5 shrink-0"

View File

@@ -0,0 +1,67 @@
import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PdfControls from './PdfControls.svelte';
afterEach(cleanup);
const defaultProps = {
currentPage: 1,
totalPages: 3,
isLoaded: true,
showAnnotations: false,
annotationCount: 0,
onPrev: vi.fn(),
onNext: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onToggleAnnotations: vi.fn()
};
describe('PdfControls — annotation toggle visibility', () => {
it('renders annotation toggle when annotationCount is greater than zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 3 });
await expect
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
.toBeInTheDocument();
});
it('does not render annotation toggle when annotationCount is zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 0 });
await expect
.element(page.getByRole('button', { name: /annotierungen/i }))
.not.toBeInTheDocument();
});
});
describe('PdfControls — annotation toggle label', () => {
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
await expect.element(btn).toBeInTheDocument();
});
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
await expect.element(btn).toBeInTheDocument();
});
});
describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
it('uses text-primary class on annotation toggle button when annotations are hidden', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('text-primary');
expect(annotationBtn!.className).not.toContain('text-accent');
});
});

View File

@@ -18,6 +18,7 @@ let {
activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick,
onTranscriptionDraw,
onDeleteAnnotationRequest,
documentFileHash,
annotationsDimmed = false,
flashAnnotationId = null
@@ -30,6 +31,7 @@ let {
activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
documentFileHash?: string | null;
annotationsDimmed?: boolean;
flashAnnotationId?: string | null;
@@ -264,6 +266,7 @@ function handleAnnotationClick(id: string) {
flashAnnotationId={flashAnnotationId}
onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick}
onDeleteRequest={onDeleteAnnotationRequest}
/>
{/if}
</div>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { untrack } from 'svelte';
import { radioGroupNav } from '$lib/actions/radioGroupNav';
import { m } from '$lib/paraglide/messages.js';
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
let {
value = 'PERSON',
name = 'personType',
onchange
}: { value?: string; name?: string; onchange?: (type: PersonType) => void } = $props();
let selected = $state<PersonType>(
untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON'))
);
let announcement = $state('');
const labels: Record<PersonType, () => string> = {
PERSON: m.person_type_PERSON,
INSTITUTION: m.person_type_INSTITUTION,
GROUP: m.person_type_GROUP,
UNKNOWN: m.person_type_UNKNOWN
};
function select(type: PersonType) {
selected = type;
announcement = m.a11y_type_changed({ type: labels[type]() });
onchange?.(type);
}
</script>
<div
role="radiogroup"
aria-label={m.form_label_person_type()}
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
>
{#each TYPES as type (type)}
<button
type="button"
role="radio"
value={type}
aria-checked={selected === type}
tabindex={selected === type ? 0 : -1}
onclick={() => select(type)}
class="min-h-[48px] cursor-pointer rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected === type
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink hover:border-primary/50'}"
>
{labels[type]()}
</button>
{/each}
</div>
<input type="hidden" name={name} value={selected} />
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import PersonTypeSelector from './PersonTypeSelector.svelte';
afterEach(() => cleanup());
describe('PersonTypeSelector', () => {
it('radiogroup has an accessible name via aria-label', () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const radiogroup = container.querySelector('[role="radiogroup"]');
expect(radiogroup).not.toBeNull();
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
});
it('hidden input value updates when user navigates with ArrowRight', async () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(hiddenInput.value).toBe('PERSON');
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
personButton.focus();
await userEvent.keyboard('{ArrowRight}');
expect(hiddenInput.value).toBe('INSTITUTION');
});
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(hiddenInput.value).toBe('PERSON');
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
personButton.focus();
await userEvent.keyboard('{ArrowLeft}');
expect(hiddenInput.value).toBe('UNKNOWN');
});
it('exactly one button is aria-checked=true for the initial value', () => {
const { container } = render(PersonTypeSelector, { value: 'INSTITUTION' });
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
const checked = buttons.filter((b) => b.getAttribute('aria-checked') === 'true');
const unchecked = buttons.filter((b) => b.getAttribute('aria-checked') === 'false');
expect(checked).toHaveLength(1);
expect(unchecked).toHaveLength(3);
});
it('aria-checked=true moves to clicked button on click', async () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
await userEvent.click(groupButton);
expect(groupButton.getAttribute('aria-checked')).toBe('true');
const others = buttons.filter((b) => b !== groupButton);
for (const btn of others) {
expect(btn.getAttribute('aria-checked')).toBe('false');
}
});
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
expect(selected!.getAttribute('tabindex')).toBe('0');
for (const btn of unselected) {
expect(btn.getAttribute('tabindex')).toBe('-1');
}
});
});

View File

@@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
type Person = components['schemas']['Person'];
interface Props {
@@ -18,6 +19,7 @@ interface Props {
autofocus?: boolean;
required?: boolean;
restrictToCorrespondentsOf?: string;
badge?: 'additive' | 'replace';
onchange?: (value: string) => void;
onfocused?: () => void;
}
@@ -34,6 +36,7 @@ let {
autofocus = false,
required = false,
restrictToCorrespondentsOf,
badge,
onchange,
onfocused
}: Props = $props();
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
class={compact
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
: 'block text-sm font-medium text-ink-2'}
>{label}{#if required}*{/if}</label
>{label}{#if required}*{/if}{#if badge}<FieldLabelBadge variant={badge} />{/if}</label
>
<input type="hidden" name={name} bind:value={value} />

View File

@@ -19,7 +19,7 @@ let { percentage }: { percentage: number } = $props();
/>
</svg>
<span
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-primary' : 'text-gray-400'}"
>
{percentage}%
</span>

View File

@@ -25,12 +25,12 @@ describe('ProgressRing', () => {
expect(el.className).toContain('text-gray-400');
});
it('renders a mint-colored label when percentage is > 0', async () => {
it('renders a primary-colored label when percentage is > 0', async () => {
render(ProgressRing, { percentage: 75 });
const label = page.getByText('75%');
await expect.element(label).toBeInTheDocument();
const el = (await label.element()) as HTMLElement;
expect(el.className).toContain('text-accent');
expect(el.className).toContain('text-primary');
});
it('renders a fully filled arc for 100%', async () => {

View File

@@ -3,11 +3,21 @@ type Props = {
icon: string;
title: string;
body: string;
beispielInput?: string;
beispielInputStrike?: boolean;
beispielOutput?: string;
beispielLabel?: string;
};
let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $props();
let {
icon,
title,
body,
beispielInput,
beispielInputStrike = false,
beispielOutput,
beispielLabel = 'Beispiel'
}: Props = $props();
</script>
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
@@ -18,12 +28,18 @@ let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
{#if beispielOutput !== undefined}
<div class="border-brand-sand mt-4 rounded-sm border bg-[#FAF8F1] px-4 py-3">
<div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
{beispielLabel}
</p>
<p class="mt-1 font-sans text-sm text-ink">
<code class="font-mono">{beispielOutput}</code>
{#if beispielInput !== undefined}
<code
class={['font-mono', beispielInputStrike && 'line-through'].filter(Boolean).join(' ')}
>{beispielInput}</code
>
{/if}
<code class="font-mono">{beispielOutput}</code>
</p>
</div>
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type Theme = 'light' | 'dark';
@@ -19,6 +20,10 @@ onMount(() => {
theme = resolveInitialTheme();
});
const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
@@ -29,8 +34,8 @@ function toggle() {
<button
type="button"
onclick={toggle}
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
title={theme === 'dark' ? 'light mode' : 'dark mode'}
aria-label={themeLabel}
title={themeLabel}
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if theme === 'dark'}

View File

@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ThemeToggle from './ThemeToggle.svelte';
afterEach(() => {
cleanup();
localStorage.removeItem('theme');
});
describe('ThemeToggle — label derivation (light mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('aria-label invites switching to dark mode when theme is light', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
});
it('title equals aria-label in light mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});
describe('ThemeToggle — label derivation (dark mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'dark');
});
it('aria-label invites switching to light mode when theme is dark', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
});
it('title equals aria-label in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});

View File

@@ -13,7 +13,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
<!-- Step 1 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
<li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
@@ -27,7 +27,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</li>
<!-- Step 2 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
<li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
@@ -40,7 +40,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</li>
<!-- Step 3 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
<li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"

View File

@@ -1,7 +1,19 @@
<script lang="ts">
const prefersReducedMotion = $derived(
// $derived from .matches is a one-shot snapshot — it doesn't react when the
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
let prefersReducedMotion = $state(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
$effect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotion = e.matches;
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
});
</script>
{#if prefersReducedMotion}
@@ -10,7 +22,7 @@ const prefersReducedMotion = $derived(
role="img"
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
class="border-brand-sand block w-full rounded-sm border bg-parchment"
>
<g
stroke="#2a2a2a"
@@ -61,7 +73,7 @@ const prefersReducedMotion = $derived(
role="img"
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
class="border-brand-sand block w-full rounded-sm border bg-parchment"
>
<!-- Kurrent writing (static) -->
<g

View File

@@ -19,6 +19,7 @@ type Props = {
onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>;
onMarkAllReviewed?: () => Promise<void>;
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
canWrite?: boolean;
trainingLabels?: string[];
@@ -37,6 +38,7 @@ let {
onSaveBlock,
onDeleteBlock,
onReviewToggle,
onMarkAllReviewed,
onTriggerOcr,
canWrite = false,
trainingLabels = [],
@@ -46,12 +48,14 @@ let {
let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0);
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
const totalCount = $derived(blocks.length);
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
$effect(() => {
@@ -60,6 +64,16 @@ $effect(() => {
if (block) activeBlockId = block.id;
});
async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return;
markingAllReviewed = true;
try {
await onMarkAllReviewed();
} finally {
markingAllReviewed = false;
}
}
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
const dragDrop = createBlockDragDrop({
@@ -147,9 +161,56 @@ async function handleLabelToggle(label: string) {
{#if hasBlocks}
<!-- Sticky review progress header -->
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
<p class="font-sans text-xs text-ink-2">
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
</p>
<div class="flex items-center justify-between">
<p class="font-sans text-xs text-ink-2">
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
</p>
{#if onMarkAllReviewed}
<button
onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed}
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
>
{#if markingAllReviewed}
<svg
class="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{/if}
Alle als fertig markieren
</button>
{/if}
</div>
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
<div
class="h-full rounded-full bg-brand-mint transition-all duration-300"

View File

@@ -49,6 +49,11 @@ function renderView(overrides: Record<string, unknown> = {}, service = createCon
};
}
const unreviewedBlock1 = { ...block1, reviewed: false };
const unreviewedBlock2 = { ...block2, reviewed: false };
const reviewedBlock1 = { ...block1, reviewed: true };
const reviewedBlock2 = { ...block2, reviewed: true };
describe('TranscriptionEditView — rendering', () => {
it('renders blocks in sort order', async () => {
renderView();
@@ -269,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => {
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
});
});
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
describe('TranscriptionEditView — mark all reviewed', () => {
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument();
});
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument();
});
it('disables button when all blocks are already reviewed', async () => {
renderView({
blocks: [reviewedBlock1, reviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled();
});
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
});
it('disables button while operation is in-flight', async () => {
let resolveMarkAll!: () => void;
const onMarkAllReviewed = vi
.fn()
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
await btn.click();
await expect.element(btn).toBeDisabled();
resolveMarkAll();
});
});

View File

@@ -177,6 +177,6 @@ describe('TranscriptionPanelHeader', () => {
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
});

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { onDestroy, untrack } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import BulkDropZone from './BulkDropZone.svelte';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
import type { FileEntry } from './FileSwitcherStrip.svelte';
@@ -19,6 +20,17 @@ import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route
// passes the parsed `/api/documents/batch-metadata` response straight in, so
// the field names must match what the backend actually serializes (id, not
// documentId). The FileEntry built from each summary still uses both `id` and
// `documentId` so the save handler can drive the PATCH payload by UUID.
export type BulkEditEntry = {
id: string;
title: string;
pdfUrl: string;
};
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
let _confirmService: ConfirmService | null;
try {
@@ -28,13 +40,17 @@ try {
}
let {
mode = 'upload',
initialSenderId = '',
initialSenderName = '',
initialReceivers = []
initialReceivers = [],
initialEditEntries = []
}: {
mode?: 'upload' | 'edit';
initialSenderId?: string;
initialSenderName?: string;
initialReceivers?: Person[];
initialEditEntries?: BulkEditEntry[];
} = $props();
// --- File state ---
@@ -42,12 +58,36 @@ let files = new SvelteMap<string, FileEntry>();
let activeId = $state<string | null>(null);
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
let saving = $state(false);
// Partial-failure surface: when set, the last save aborted at chunk N of M.
let partialSaved = $state<{ done: number; total: number } | null>(null);
// --- Shared metadata ---
let senderId = $state(untrack(() => initialSenderId));
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
let dateIso = $state('');
let tags = $state<Tag[]>([]);
// Bulk-edit only — replace-on-non-blank semantics.
let archiveBox = $state('');
let archiveFolder = $state('');
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
// fetch upstream in the route — by the time this layout mounts, the metadata
// has already been resolved into `initialEditEntries`. Wrapped in onMount so
// the SvelteMap mutation is unambiguously tied to instance lifecycle, not to
// the script body's first execution (Felix C4 cycle 3).
onMount(() => {
if (mode !== 'edit') return;
for (const entry of untrack(() => initialEditEntries)) {
files.set(entry.id, {
id: entry.id,
documentId: entry.id,
title: entry.title,
status: 'idle',
previewUrl: entry.pdfUrl
});
if (!activeId) activeId = entry.id;
}
});
// --- Derived ---
const isMulti = $derived(files.size >= 2);
@@ -96,6 +136,16 @@ async function handleDiscard() {
});
if (!ok) return;
}
if (mode === 'edit') {
// In edit mode the file map IS the user's bulk selection — discarding
// must clear the upstream store and bounce back to the list, otherwise
// the user is left on /documents/bulk-edit with an empty form and a
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
bulkSelectionStore.clear();
discardAll();
await goto('/documents');
return;
}
discardAll();
}
@@ -105,10 +155,8 @@ onDestroy(() => {
}
});
// --- Save ---
async function save() {
if (saving) return;
saving = true;
// --- Save (upload mode) ---
async function saveUpload() {
const entries = Array.from(files.values());
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
const chunkSize = 10;
@@ -122,7 +170,7 @@ async function save() {
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const formData = new FormData();
chunk.forEach((entry) => formData.append('files', entry.file));
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
const metadata = {
titles: chunk.map((e) => e.title),
senderId: senderId || null,
@@ -143,8 +191,8 @@ async function save() {
if (!res.ok || errorFilenames.size > 0) {
hadErrors = true;
for (const entry of chunk) {
// When backend names specific files, mark only those; otherwise mark all.
const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true;
const filename = entry.file?.name;
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
if (isError) {
const e = files.get(entry.id);
if (e) files.set(entry.id, { ...e, status: 'error' });
@@ -160,9 +208,96 @@ async function save() {
}
chunkProgress = { done: i + 1, total: chunks.length };
}
saving = false;
if (!hadErrors) goto('/documents');
}
// --- Save (edit mode) ---
async function saveBulkEdit() {
const entries = Array.from(files.values());
const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x);
// PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk
// failure so the user sees a deterministic "X of N saved" outcome.
const chunkSize = 500;
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += chunkSize) {
chunks.push(ids.slice(i, i + chunkSize));
}
chunkProgress = { done: 0, total: chunks.length };
partialSaved = null;
const dto = {
tagNames: tags.map((t) => t.name),
senderId: senderId || null,
receiverIds: selectedReceivers.map((r) => r.id),
archiveBox: archiveBox || null,
archiveFolder: archiveFolder || null
};
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
const res = await fetch('/api/documents/bulk', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dto, documentIds: chunk })
});
if (!res.ok) {
// Network/server failure: the chunk did not apply. Mark its entries
// as errored, surface partial-save state, and stop.
for (const id of chunk) {
const e = files.get(id);
if (e) files.set(id, { ...e, status: 'error' });
}
partialSaved = { done: i, total: chunks.length };
return;
}
const body = (await res.json().catch(() => null)) as {
updated: number;
errors: { id: string; message: string }[];
} | null;
if (body && body.errors && body.errors.length > 0) {
for (const err of body.errors) {
const e = files.get(err.id);
if (e) files.set(err.id, { ...e, status: 'error' });
}
}
} catch {
for (const id of chunk) {
const e = files.get(id);
if (e) files.set(id, { ...e, status: 'error' });
}
partialSaved = { done: i, total: chunks.length };
return;
}
chunkProgress = { done: i + 1, total: chunks.length };
}
const stillErrored = Array.from(files.values()).some((e) => e.status === 'error');
if (!stillErrored) {
bulkSelectionStore.clear();
goto('/documents');
}
}
async function save() {
if (saving) return;
saving = true;
try {
if (mode === 'edit') {
await saveBulkEdit();
} else {
await saveUpload();
}
} finally {
saving = false;
}
}
async function retrySave() {
partialSaved = null;
await save();
}
</script>
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
@@ -190,12 +325,20 @@ async function save() {
</a>
<span class="text-ink-3" aria-hidden="true">·</span>
<span class="font-serif text-sm font-bold text-ink">
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
{#if mode === 'edit'}
{m.bulk_edit_topbar_title()}
{:else}
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
{/if}
</span>
{#if isMulti}
<span class="ml-auto flex items-center gap-3">
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
{m.bulk_count_pill({ count: files.size })}
{#if mode === 'edit'}
{m.bulk_edit_count_pill({ count: files.size })}
{:else}
{m.bulk_count_pill({ count: files.size })}
{/if}
</span>
<button
type="button"
@@ -213,11 +356,11 @@ async function save() {
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview / drop zone (55%) -->
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
{#if files.size === 0}
<!-- N=0: centred drop-zone box fills the panel -->
{#if mode === 'upload' && files.size === 0}
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
<BulkDropZone onFilesAdded={addFiles} />
{:else}
<!-- N≥1: real PDF preview via local blob URL -->
{:else if files.size > 0}
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
<div class="relative flex-1 overflow-hidden">
{#if activeFile}
<PdfViewer url={activeFile.previewUrl} />
@@ -243,22 +386,46 @@ async function save() {
class:opacity-60={files.size === 0}
class:pointer-events-none={files.size === 0}
>
{#if mode === 'edit'}
<!-- Onboarding callout: tells the user that empty fields are skipped
and that tags/receivers are added rather than replaced.
No aria-label — role=note + the visible text content is
self-describing; an aria-label would override that text for
AT users on non-DE locales. -->
<div
role="note"
data-testid="bulk-edit-callout"
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
>
{m.bulk_edit_hint()}
</div>
{/if}
{#if isMulti}
<!-- N≥2: per-file card (title) + shared card (metadata) -->
<ScopeCard variant="per-file">
{#if activeFile}
<label class="block">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
/>
</label>
{#if mode === 'edit'}
<div data-testid="readonly-title">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()}
</span>
<p class="font-serif text-base text-ink">{activeFile.title}</p>
</div>
{:else}
<label class="block">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
/>
</label>
{/if}
{/if}
</ScopeCard>
@@ -268,33 +435,50 @@ async function save() {
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
hideDate={mode === 'edit'}
editMode={mode === 'edit'}
/>
<DescriptionSection
bind:tags={tags}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
editMode={mode === 'edit'}
/>
<DescriptionSection bind:tags={tags} hideTitle />
</ScopeCard>
{:else}
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<label class="block">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
{#if activeFile}
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{:else}
<input
type="text"
disabled
placeholder="—"
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
/>
{/if}
</label>
{#if mode === 'edit' && activeFile}
<div data-testid="readonly-title">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()}
</span>
<p class="font-serif text-base text-ink">{activeFile.title}</p>
</div>
{:else}
<label class="block">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
{#if activeFile}
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{:else}
<input
type="text"
disabled
placeholder="—"
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
/>
{/if}
</label>
{/if}
</div>
<WhoWhenSection
@@ -302,8 +486,38 @@ async function save() {
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
hideDate={mode === 'edit'}
editMode={mode === 'edit'}
/>
<DescriptionSection bind:tags={tags} hideTitle />
<DescriptionSection
bind:tags={tags}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
editMode={mode === 'edit'}
/>
{/if}
{#if partialSaved}
<div
role="alert"
data-testid="bulk-edit-partial-failure"
class="rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
>
<p class="font-medium">
{m.bulk_edit_save_partial({
done: partialSaved.done,
total: partialSaved.total
})}
</p>
<button
type="button"
onclick={retrySave}
class="mt-2 inline-flex items-center bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.bulk_edit_retry()}
</button>
</div>
{/if}
</div>
@@ -314,6 +528,7 @@ async function save() {
onSave={save}
onDiscard={handleDiscard}
disabled={saving}
editMode={mode === 'edit'}
/>
</div>
</div>

View File

@@ -3,7 +3,6 @@ import { goto } from '$app/navigation';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
@@ -290,29 +289,6 @@ describe('BulkDocumentEditLayout', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('discard-all does not clear files when the user cancels the confirm dialog', async () => {
const service = createConfirmService();
const { container } = render(BulkDocumentEditLayout, {
context: new Map([[CONFIRM_KEY, service]])
});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
discardBtn.click();
// The confirm dialog should open (service.options not null)
await vi.waitFor(() => expect(service.options).not.toBeNull(), { timeout: 1000 });
// Cancel — files should remain
service.settle(false);
await vi.waitFor(
() => expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(),
{ timeout: 1000 }
);
});
it('discard-all resets to N=0 state and shows drop zone', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
@@ -336,3 +312,232 @@ describe('BulkDocumentEditLayout', () => {
);
});
});
// ─── mode="edit" ─────────────────────────────────────────────────────────────
describe('BulkDocumentEditLayout — mode="edit" discard', () => {
it('discard in edit mode clears the selection store and navigates back to /documents', async () => {
const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte');
bulkSelectionStore.setAll(['doc-1']);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [
{ id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' },
{ id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' }
]
});
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
discardBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 });
expect(bulkSelectionStore.size).toBe(0);
});
});
describe('BulkDocumentEditLayout — mode="edit"', () => {
const editEntry = (i: number) => ({
id: `doc-${i}`,
title: `Brief ${i}`,
pdfUrl: `/api/documents/doc-${i}/file`
});
it('does not render the BulkDropZone in edit mode', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
});
it('renders the onboarding callout with role=note in edit mode', async () => {
render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
const callout = page.getByTestId('bulk-edit-callout');
await expect.element(callout).toBeInTheDocument();
await expect.element(callout).toHaveAttribute('role', 'note');
});
it('renders read-only title display (no input) in edit mode', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
// Per-file ScopeCard absent at N=1 — title rendered in the single card
const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
expect(titleInput).toBeNull();
});
it('hides the date field via WhoWhenSection hideDate prop', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
});
it('shows additive badge next to tags label', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
});
it('shows replace badges next to sender and archive fields', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
// sender + archiveBox + archiveFolder = 3
expect(replaceBadges.length).toBeGreaterThanOrEqual(3);
});
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
// Elicit C1 fix — upload-flavoured "Mehrere Dokumente hochladen" /
// "werden erstellt" copy must not appear when mode === 'edit'.
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
// Topbar title slot
const topbar = container.querySelector('span.font-bold.text-ink');
expect(topbar?.textContent).toContain('Massenbearbeitung');
// Count pill
const pill = container.querySelector('span.bg-accent');
expect(pill?.textContent).toContain('werden bearbeitet');
// Negative: must NOT show upload-flavoured copy
expect(topbar?.textContent ?? '').not.toContain('hochladen');
expect(pill?.textContent ?? '').not.toContain('werden erstellt');
});
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('save calls PATCH /api/documents/bulk in edit mode', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ updated: 2, errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/documents/bulk');
expect(init.method).toBe('PATCH');
const body = JSON.parse(init.body);
expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
});
it('chunks IDs into 500-sized PATCH requests', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ updated: 500, errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: entries
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
});
it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
vi.stubGlobal('fetch', mockFetch);
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: entries
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
expect(alert).not.toBeNull();
},
{ timeout: 5000 }
);
// Should have called twice — chunks 0 and 1 — but not the third.
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(goto)).not.toHaveBeenCalled();
});
it('marks per-document error chips when service returns errors[]', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
updated: 1,
errors: [{ id: 'doc-2', message: 'Sender not found' }]
})
})
);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const errorChip = container.querySelector(
'[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
);
expect(errorChip).not.toBeNull();
},
{ timeout: 3000 }
);
});
});

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { canWrite }: { canWrite: boolean } = $props();
const count = $derived(bulkSelectionStore.size);
const visible = $derived(canWrite && count > 0);
function openBulkEdit() {
goto('/documents/bulk-edit');
}
function clearAll() {
bulkSelectionStore.clear();
}
// Escape clears the selection — keyboard escape hatch when the user has
// drilled into a 50-row selection and wants to bail without Tab-ing through
// the whole footer (WCAG 2.1.1). Bails when an open dialog, expanded menu,
// or popover is in front so we don't steal Esc from NotificationBell,
// ConfirmDialog, HelpPopover, etc.
function onEscape(e: KeyboardEvent) {
if (e.key !== 'Escape' || !visible) return;
if (e.defaultPrevented) return;
const overlay = document.querySelector(
'dialog[open], [aria-expanded="true"], [role="menu"]:not([hidden]), [role="dialog"]:not([hidden])'
);
if (overlay) return;
clearAll();
}
</script>
<svelte:window onkeydown={onEscape} />
{#if visible}
<div
data-testid="bulk-selection-bar"
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between gap-3 border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
>
<div class="flex items-baseline gap-3">
<span
class="font-sans text-sm font-medium text-ink"
data-testid="bulk-selection-count"
aria-live="polite"
aria-atomic="true"
>
{count === 1 ? m.bulk_edit_n_selected_one() : m.bulk_edit_n_selected_other({ count })}
</span>
<span class="hidden font-sans text-xs text-ink-3 sm:inline">
{m.bulk_edit_clear_hint_keyboard()}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={clearAll}
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
data-testid="bulk-clear-all"
>
{m.bulk_edit_clear_selection()}
</button>
<button
type="button"
onclick={openBulkEdit}
class="inline-flex min-h-[44px] items-center bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
data-testid="bulk-edit-open"
>
{m.bulk_edit_button()}
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import BulkSelectionBar from './BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.mocked(goto).mockClear();
bulkSelectionStore.clear();
});
describe('BulkSelectionBar', () => {
it('does not render when canWrite is false', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: false });
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
});
it('does not render when selection is empty', async () => {
render(BulkSelectionBar, { canWrite: true });
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
});
it('renders with the current selection count', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2');
});
it('uses the singular plural form for count=1 (not "1 Dokumente")', async () => {
bulkSelectionStore.add('only');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveTextContent('1 Dokument ausgewählt');
});
it('uses the plural form for count=2', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveTextContent('2 Dokumente ausgewählt');
});
it('clear button empties the store', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await page.getByTestId('bulk-clear-all').click();
expect(bulkSelectionStore.size).toBe(0);
});
it('Massenbearbeitung navigates to /documents/bulk-edit', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
await page.getByTestId('bulk-edit-open').click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
});
it('selection count region announces via aria-live=polite', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveAttribute('aria-live', 'polite');
});
it('Escape clears the selection while the bar is visible', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await expect.poll(() => bulkSelectionStore.size).toBe(0);
});
it('Escape is a no-op when the bar is hidden (no selection)', async () => {
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Nothing to clear, no error.
expect(bulkSelectionStore.size).toBe(0);
});
it('Escape does not clear when an open <dialog> is present (Leonie B6 scope guard)', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
// Simulate a ConfirmDialog being open in front of the bar.
const overlay = document.createElement('dialog');
overlay.setAttribute('open', '');
document.body.appendChild(overlay);
try {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Escape is captured by the dialog, not the bar — selection survives.
expect(bulkSelectionStore.size).toBe(2);
} finally {
overlay.remove();
}
});
it('Escape does not clear when an aria-expanded popover is present', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
const trigger = document.createElement('button');
trigger.setAttribute('aria-expanded', 'true');
document.body.appendChild(trigger);
try {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(bulkSelectionStore.size).toBe(1);
} finally {
trigger.remove();
}
});
});

View File

@@ -1,30 +1,51 @@
<script lang="ts">
import { untrack } from 'svelte';
import { onMount } from 'svelte';
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
tags = $bindable<Tag[]>([]),
currentTitle = $bindable(''),
documentLocation = $bindable(''),
archiveBox = $bindable(''),
archiveFolder = $bindable(''),
initialTitle = '',
initialDocumentLocation = '',
initialArchiveBox = '',
initialArchiveFolder = '',
initialSummary = '',
titleRequired = false,
suggestedTitle = '',
hideTitle = false
hideTitle = false,
editMode = false
}: {
tags?: Tag[];
currentTitle?: string;
documentLocation?: string;
archiveBox?: string;
archiveFolder?: string;
initialTitle?: string;
initialDocumentLocation?: string;
initialArchiveBox?: string;
initialArchiveFolder?: string;
initialSummary?: string;
titleRequired?: boolean;
suggestedTitle?: string;
hideTitle?: boolean;
editMode?: boolean;
} = $props();
// Seed bindables from initial-* props once at mount and only when the parent
// hasn't already supplied a non-empty value through the binding. onMount runs
// exactly once per instance, so this never stomps a parent-driven update on a
// later prop change. Required by the single-doc edit flow which seeds from
// the document; bulk-edit consumers leave the initial-* unset and bind their
// own state.
let titleDirty = $state(false);
currentTitle = untrack(() => initialTitle);
onMount(() => {
if (!currentTitle && initialTitle) currentTitle = initialTitle;
if (!archiveBox && initialArchiveBox) archiveBox = initialArchiveBox;
if (!archiveFolder && initialArchiveFolder) archiveFolder = initialArchiveFolder;
});
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
</script>
@@ -67,40 +88,61 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
<!-- Schlagworte (optional) -->
<div>
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
<p class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_tags()}
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
</p>
<TagInput bind:tags={tags} />
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
</div>
<!-- Inhalt (optional) -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{initialSummary}</textarea
>
</div>
{#if !editMode}
<!-- Inhalt (optional) — not bulk-editable. -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{initialSummary}</textarea
>
</div>
{/if}
<!-- Aufbewahrungsort (optional) -->
<div>
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_archive_location()}</label
>
<!-- Karton -->
<div data-testid="description-archive-box">
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_box()}
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
</label>
<input
id="documentLocation"
id="archiveBox"
type="text"
name="documentLocation"
value={initialDocumentLocation}
placeholder={m.form_placeholder_archive_location()}
name="archiveBox"
bind:value={archiveBox}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
</div>
<!-- Mappe -->
<div data-testid="description-archive-folder">
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_folder()}
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
</label>
<input
id="archiveFolder"
type="text"
name="archiveFolder"
bind:value={archiveFolder}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import DescriptionSection from './DescriptionSection.svelte';
afterEach(() => cleanup());
describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fence)', () => {
it('pre-fills the title input from initialTitle when currentTitle is empty', async () => {
render(DescriptionSection, { initialTitle: 'Brief an Anna' });
const titleInput = document.querySelector('input#title') as HTMLInputElement;
expect(titleInput).not.toBeNull();
expect(titleInput.value).toBe('Brief an Anna');
});
it('does not stomp a parent-bound currentTitle that is already non-empty', async () => {
render(DescriptionSection, {
currentTitle: 'Parent Title',
initialTitle: 'Should Not Win'
});
const titleInput = document.querySelector('input#title') as HTMLInputElement;
expect(titleInput.value).toBe('Parent Title');
});
it('always renders archiveBox + archiveFolder fields regardless of editMode', async () => {
render(DescriptionSection, { editMode: false });
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
render(DescriptionSection, { editMode: true, hideTitle: true });
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('pre-fills archiveBox from initialArchiveBox when archiveBox is empty', async () => {
render(DescriptionSection, { initialArchiveBox: 'K-03', hideTitle: true });
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
expect(input.value).toBe('K-03');
});
it('pre-fills archiveFolder from initialArchiveFolder when archiveFolder is empty', async () => {
render(DescriptionSection, { initialArchiveFolder: 'Mappe B', hideTitle: true });
const input = document.querySelector('input#archiveFolder') as HTMLInputElement;
expect(input.value).toBe('Mappe B');
});
it('does not stomp a parent-bound archiveBox that is already non-empty', async () => {
render(DescriptionSection, {
archiveBox: 'Parent Value',
initialArchiveBox: 'Should Not Win',
hideTitle: true
});
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
expect(input.value).toBe('Parent Value');
});
});

View File

@@ -207,7 +207,8 @@ async function handleReplaceFile(e: Event) {
bind:tags={tags}
bind:currentTitle={currentTitle}
initialTitle={doc.title ?? ''}
initialDocumentLocation={doc.documentLocation ?? ''}
initialArchiveBox={doc.archiveBox ?? ''}
initialArchiveFolder={doc.archiveFolder ?? ''}
initialSummary={doc.summary ?? ''}
titleRequired={true}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { variant }: { variant: 'additive' | 'replace' } = $props();
const text = $derived(
variant === 'additive' ? m.bulk_edit_badge_additive() : m.bulk_edit_badge_replace()
);
</script>
<span
data-testid="field-label-badge-{variant}"
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[11px] font-medium tracking-wide text-ink-2"
>
{text}
</span>

View File

@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import FieldLabelBadge from './FieldLabelBadge.svelte';
afterEach(() => cleanup());
describe('FieldLabelBadge', () => {
it('renders the additive variant text', async () => {
render(FieldLabelBadge, { variant: 'additive' });
await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument();
await expect
.element(page.getByTestId('field-label-badge-additive'))
.toHaveTextContent('+ wird hinzugefügt');
});
it('renders the replace variant text', async () => {
render(FieldLabelBadge, { variant: 'replace' });
await expect
.element(page.getByTestId('field-label-badge-replace'))
.toHaveTextContent('wird ersetzt');
});
it('uses the design-system text-ink-2 token (not raw Tailwind palette)', async () => {
render(FieldLabelBadge, { variant: 'replace' });
await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/);
});
});

View File

@@ -4,7 +4,11 @@ import { m } from '$lib/paraglide/messages.js';
export interface FileEntry {
id: string;
file: File;
/** Present in upload mode only. Edit mode entries reference an existing
* document by `documentId` and have no local file blob. */
file?: File;
/** Present in edit mode only — the server-side document UUID being edited. */
documentId?: string;
title: string;
status: 'idle' | 'error';
previewUrl: string;

View File

@@ -6,14 +6,21 @@ let {
chunkProgress,
onSave,
onDiscard,
disabled = false
disabled = false,
editMode = false
}: {
fileCount: number;
chunkProgress?: { done: number; total: number };
onSave: () => void;
onDiscard: () => void | Promise<void>;
disabled?: boolean;
editMode?: boolean;
} = $props();
const saveCta = $derived.by(() => {
if (editMode) return m.bulk_edit_save_button();
return fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount });
});
</script>
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
@@ -24,9 +31,22 @@ let {
aria-valuenow={chunkProgress.done}
aria-valuemin={0}
aria-valuemax={chunkProgress.total}
aria-label={m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
class="[&::-webkit-progress-bar]:bg-brand-sand mb-3 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
aria-label={editMode
? m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })
: m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
class="[&::-webkit-progress-bar]:bg-brand-sand mb-2 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
></progress>
{#if editMode && chunkProgress.total > 1}
<!-- Visible progress text for sighted users on multi-chunk PATCH
(Elicit S3 — the unitless bar isn't enough for a 30-second op). -->
<p
class="mb-2 font-sans text-xs text-ink-2"
aria-live="polite"
data-testid="bulk-edit-chunk-progress-text"
>
{m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })}
</p>
{/if}
{/if}
<div class="flex items-center justify-between gap-3">
<button
@@ -43,7 +63,7 @@ let {
onclick={onSave}
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
>
{fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount })}
{saveCta}
</button>
</div>
</div>

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
@@ -16,7 +17,9 @@ let {
initialLocation = '',
initialSenderName = '',
suggestedDateIso = '',
suggestedSenderName = ''
suggestedSenderName = '',
hideDate = false,
editMode = false
}: {
senderId?: string;
selectedReceivers?: Person[];
@@ -26,12 +29,24 @@ let {
initialSenderName?: string;
suggestedDateIso?: string;
suggestedSenderName?: string;
hideDate?: boolean;
editMode?: boolean;
} = $props();
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
dateIso = untrack(() => initialDateIso);
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
// and is then user-driven. onMount runs exactly once, so this never stomps
// the parent's dateIso on a later prop change.
let dateDisplay = $state('');
let dateDirty = $state(false);
onMount(() => {
const seed = dateIso || initialDateIso;
if (seed) {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
});
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) {
@@ -56,60 +71,72 @@ $effect(() => {
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<!-- Datum (required — row 1, col 1) -->
<div>
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_date()}*</label
>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
{#if !hideDate}
<!-- Datum (required — row 1, col 1) -->
<div data-testid="who-when-date">
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_date()}*</label
>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateInvalid
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
{/if}
<!-- Absender (required — row 1, col 2) -->
<!-- Absender (required in upload mode — row 1, col 2) -->
<div>
<PersonTypeahead
name="senderId"
label={m.form_label_sender()}
required={true}
required={!editMode}
bind:value={senderId}
initialName={initialSenderName}
suggestedName={suggestedSenderName}
badge={editMode ? 'replace' : undefined}
/>
</div>
<!-- Empfänger (optional — row 2, col 1) -->
<div>
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
<p class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_receivers()}
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
</p>
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
</div>
<!-- Ort (optional — row 2, col 2) -->
<div>
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_location()}</label
>
<input
id="location"
type="text"
name="location"
value={initialLocation}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{#if !editMode}
<!-- Ort (optional — row 2, col 2). Hidden in editMode: meta_location is
NOT bulk-editable per the issue spec; the three editable location
fields live in DescriptionSection. -->
<div>
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_location()}</label
>
<input
id="location"
type="text"
name="location"
value={initialLocation}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import WhoWhenSection from './WhoWhenSection.svelte';
afterEach(() => cleanup());
describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', () => {
it('pre-fills the date input from initialDateIso when the bindable is empty', async () => {
render(WhoWhenSection, { initialDateIso: '2024-03-15' });
// isoToGerman('2024-03-15') → '15.03.2024'
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
expect(dateInput).not.toBeNull();
expect(dateInput.value).toBe('15.03.2024');
});
it('does not stomp a parent-bound dateIso that is already non-empty', async () => {
// dateIso bindable is '2026-01-01' from the parent; initialDateIso is the
// "fallback seed". onMount must not overwrite the already-bound value.
render(WhoWhenSection, { dateIso: '2026-01-01', initialDateIso: '1900-01-01' });
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
expect(dateInput.value).toBe('01.01.2026');
});
it('hides the date field when hideDate=true (bulk-edit mode)', async () => {
render(WhoWhenSection, { hideDate: true });
await expect.element(page.getByTestId('who-when-date')).not.toBeInTheDocument();
});
it('renders the meta_location input only outside editMode', async () => {
const { rerender } = render(WhoWhenSection, { editMode: true });
expect(document.querySelector('input#location')).toBeNull();
await rerender({ editMode: false });
expect(document.querySelector('input#location')).not.toBeNull();
});
it('pre-fills the location input from initialLocation', async () => {
render(WhoWhenSection, { editMode: false, initialLocation: 'Berlin' });
const locationInput = document.querySelector('input#location') as HTMLInputElement;
expect(locationInput.value).toBe('Berlin');
});
});

View File

@@ -7,6 +7,7 @@ import * as m from '$lib/paraglide/messages.js';
export type ErrorCode =
| 'PERSON_NOT_FOUND'
| 'ALIAS_NOT_FOUND'
| 'INVALID_PERSON_TYPE'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
@@ -42,6 +43,7 @@ export type ErrorCode =
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
| 'BATCH_TOO_LARGE'
| 'BULK_EDIT_TOO_MANY_IDS'
| 'INTERNAL_ERROR';
export interface BackendError {
@@ -72,6 +74,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_person_not_found();
case 'ALIAS_NOT_FOUND':
return m.error_alias_not_found();
case 'INVALID_PERSON_TYPE':
return m.error_invalid_person_type();
case 'DOCUMENT_NOT_FOUND':
return m.error_document_not_found();
case 'DOCUMENT_NO_FILE':
@@ -142,6 +146,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_validation_error();
case 'BATCH_TOO_LARGE':
return m.error_batch_too_large();
case 'BULK_EDIT_TOO_MANY_IDS':
return m.error_bulk_edit_too_many_ids();
default:
return m.error_internal_error();
}

View File

@@ -484,6 +484,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/batch-metadata": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["batchMetadata"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/reset-password": {
parameters: {
query?: never;
@@ -676,6 +692,22 @@ export interface paths {
patch: operations["updateAnnotation"];
trace?: never;
};
"/api/documents/bulk": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["patchBulk"];
trace?: never;
};
"/api/users/search": {
parameters: {
query?: never;
@@ -1156,6 +1188,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/ids": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getDocumentIds"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/conversation": {
parameters: {
query?: never;
@@ -1707,6 +1755,15 @@ export interface components {
filename?: string;
code?: string;
};
BatchMetadataRequest: {
ids: string[];
};
DocumentBatchSummary: {
/** Format: uuid */
id: string;
title: string;
pdfUrl: string;
};
ResetPasswordRequest: {
token?: string;
newPassword?: string;
@@ -1782,6 +1839,26 @@ export interface components {
/** Format: double */
height?: number;
};
DocumentBulkEditDTO: {
documentIds?: string[];
tagNames?: string[];
/** Format: uuid */
senderId?: string;
receiverIds?: string[];
documentLocation?: string;
archiveBox?: string;
archiveFolder?: string;
};
BulkEditError: {
/** Format: uuid */
id: string;
message: string;
};
BulkEditResult: {
/** Format: int32 */
updated: number;
errors: components["schemas"]["BulkEditError"][];
};
TranscriptionWeeklyStatsDTO: {
/** Format: int64 */
segmentationCount: number;
@@ -1833,7 +1910,6 @@ export interface components {
/** Format: uuid */
id?: string;
displayName?: string;
personType?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
@@ -1844,6 +1920,7 @@ export interface components {
deathYear?: number;
alias?: string;
notes?: string;
personType?: string;
};
SenderModel: {
/** Format: uuid */
@@ -3242,6 +3319,30 @@ export interface operations {
};
};
};
batchMetadata: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BatchMetadataRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentBatchSummary"][];
};
};
};
};
resetPassword: {
parameters: {
query?: never;
@@ -3578,6 +3679,30 @@ export interface operations {
};
};
};
patchBulk: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DocumentBulkEditDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BulkEditResult"];
};
};
};
};
search: {
parameters: {
query?: {
@@ -4244,6 +4369,36 @@ export interface operations {
};
};
};
getDocumentIds: {
parameters: {
query?: {
q?: string;
from?: string;
to?: string;
senderId?: string;
receiverId?: string;
tag?: string[];
tagQ?: string;
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
tagOp?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string[];
};
};
};
};
getConversation: {
parameters: {
query: {

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { validatePersonFields } from './person-validation';
describe('validatePersonFields', () => {
it('returns null when all required fields are present for PERSON', () => {
expect(validatePersonFields('PERSON', 'Hans', 'Müller')).toBeNull();
});
it('returns lastName error key when lastName is missing', () => {
expect(validatePersonFields('PERSON', 'Hans', '')).toBe('validation_last_name_required');
});
it('returns lastName error key when lastName is undefined', () => {
expect(validatePersonFields('INSTITUTION', undefined, undefined)).toBe(
'validation_last_name_required'
);
});
it('returns firstName error key when type is PERSON and firstName is missing', () => {
expect(validatePersonFields('PERSON', '', 'Müller')).toBe('validation_first_name_required');
});
it('returns firstName error key when type is PERSON and firstName is undefined', () => {
expect(validatePersonFields('PERSON', undefined, 'Müller')).toBe(
'validation_first_name_required'
);
});
it('returns null for INSTITUTION without firstName', () => {
expect(validatePersonFields('INSTITUTION', undefined, 'Verlag GmbH')).toBeNull();
});
it('returns null for GROUP without firstName', () => {
expect(validatePersonFields('GROUP', undefined, 'Familie Raddatz')).toBeNull();
});
it('returns null for UNKNOWN without firstName', () => {
expect(validatePersonFields('UNKNOWN', undefined, 'Unbekannt')).toBeNull();
});
});

View File

@@ -0,0 +1,39 @@
import { m } from '$lib/paraglide/messages.js';
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
export type PersonType = (typeof PERSON_TYPES)[number];
export type PersonFormData = {
personType?: string | null;
title?: string | null;
firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
notes?: string | null;
};
export function normalizePersonType(raw: string | undefined | null): PersonType {
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
}
export type PersonValidationKey =
| 'validation_last_name_required'
| 'validation_first_name_required';
export function resolveValidationMessage(key: PersonValidationKey): string {
return key === 'validation_last_name_required'
? m.validation_last_name_required()
: m.validation_first_name_required();
}
export function validatePersonFields(
personType: string,
firstName: string | undefined | null,
lastName: string | undefined | null
): PersonValidationKey | null {
if (!lastName) return 'validation_last_name_required';
if (personType === 'PERSON' && !firstName) return 'validation_first_name_required';
return null;
}

View File

@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from 'vitest';
import { bulkSelectionStore } from './bulkSelection.svelte';
describe('bulkSelectionStore', () => {
afterEach(() => bulkSelectionStore.clear());
it('starts empty', () => {
expect(bulkSelectionStore.size).toBe(0);
});
it('toggle adds an id when absent', () => {
bulkSelectionStore.toggle('a');
expect(bulkSelectionStore.has('a')).toBe(true);
expect(bulkSelectionStore.size).toBe(1);
});
it('toggle removes an id when present', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.toggle('a');
expect(bulkSelectionStore.has('a')).toBe(false);
});
it('add and remove update size', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
expect(bulkSelectionStore.size).toBe(2);
bulkSelectionStore.remove('a');
expect(bulkSelectionStore.size).toBe(1);
expect(bulkSelectionStore.has('b')).toBe(true);
});
it('add is idempotent', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('a');
expect(bulkSelectionStore.size).toBe(1);
});
it('setAll replaces the selection', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.setAll(['c', 'd', 'e']);
expect(bulkSelectionStore.size).toBe(3);
expect(bulkSelectionStore.has('a')).toBe(false);
expect(bulkSelectionStore.has('c')).toBe(true);
});
it('clear empties the selection', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.clear();
expect(bulkSelectionStore.size).toBe(0);
});
it('setAll([]) leaves the store empty (no-op when filter returned zero matches)', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.setAll([]);
expect(bulkSelectionStore.size).toBe(0);
});
it('setAll drops all previously selected ids, not just some', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.setAll(['c', 'd']);
expect(bulkSelectionStore.has('a')).toBe(false);
expect(bulkSelectionStore.has('b')).toBe(false);
expect(bulkSelectionStore.has('c')).toBe(true);
expect(bulkSelectionStore.has('d')).toBe(true);
});
it('ids getter exposes the current SvelteSet', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
const ids = Array.from(bulkSelectionStore.ids);
expect(ids.sort()).toEqual(['a', 'b']);
});
});

View File

@@ -0,0 +1,36 @@
import { SvelteSet } from 'svelte/reactivity';
// Live accumulator. Selection persists across pagination and route changes
// within /documents and /enrich. Cleared on successful bulk save or via
// "Alles aufheben". The store is module-singleton — there is only ever one
// bulk-edit selection per browser session.
const selectedIds = new SvelteSet<string>();
export const bulkSelectionStore = {
get ids(): SvelteSet<string> {
return selectedIds;
},
get size(): number {
return selectedIds.size;
},
has(id: string): boolean {
return selectedIds.has(id);
},
toggle(id: string): void {
if (selectedIds.has(id)) selectedIds.delete(id);
else selectedIds.add(id);
},
add(id: string): void {
selectedIds.add(id);
},
remove(id: string): void {
selectedIds.delete(id);
},
setAll(ids: Iterable<string>): void {
selectedIds.clear();
for (const id of ids) selectedIds.add(id);
},
clear(): void {
selectedIds.clear();
}
};

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import './layout.css';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
@@ -10,6 +10,7 @@ import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { provideConfirmService } from '$lib/services/confirm.svelte.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { children, data } = $props();
@@ -17,6 +18,27 @@ let { children, data } = $props();
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
provideConfirmService();
// Auto-clear the bulk-selection store when the user leaves the routes that
// surface the BulkSelectionBar. Without this the selection silently follows
// the user to /persons / /admin etc. and reappears as a stale 3-doc count
// when they wander back to /documents — Felix C4 on PR #331.
//
// `bulkSelectionStore.size` is read inside `untrack` so the effect only
// re-fires on route change, not on every checkbox toggle (Felix C3 cycle 3).
$effect(() => {
const path = page.url.pathname;
const inBulkContext =
path === '/documents' ||
path.startsWith('/documents/') ||
path === '/enrich' ||
path.startsWith('/enrich/');
if (!inBulkContext) {
untrack(() => {
if (bulkSelectionStore.size > 0) bulkSelectionStore.clear();
});
}
});
const isAdmin = $derived(
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
);

View File

@@ -13,14 +13,12 @@ let {
items,
canWrite,
error,
total = 0,
q = '',
sort = 'DATE'
}: {
items: DocumentSearchItem[];
canWrite: boolean;
error?: string | null;
total?: number;
q?: string;
sort?: SortMode;
} = $props();
@@ -71,29 +69,6 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
}
</script>
<!-- DOCUMENT LIST HEADER -->
<div class="mb-2 flex justify-end">
{#if canWrite}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
{/if}
</div>
<!-- RESULT COUNT -->
{#if total > 0}
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
{/if}
<!-- ERROR -->
{#if error}
<div class="border border-line bg-surface shadow-sm">
@@ -119,7 +94,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
</div>
<ul class="divide-y divide-line">
{#each group.items as item (group.label + '-' + item.document.id)}
<DocumentRow item={item} />
<DocumentRow item={item} canWrite={canWrite} />
{/each}
</ul>
</div>

View File

@@ -6,6 +6,9 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from '../SearchFilterBar.svelte';
import DocumentList from '../DocumentList.svelte';
import Pagination from '$lib/components/Pagination.svelte';
import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import { getErrorMessage, parseBackendError } from '$lib/errors';
import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -138,6 +141,50 @@ $effect(() => {
}
});
let editingAll = $state(false);
let editAllError = $state<string | null>(null);
/**
* Fast path: replace the current selection with every document matching the
* active filter (across all pages) and jump to the bulk-edit screen. The
* /api/documents/ids endpoint is hard-capped (5000 results); on cap overflow
* the backend returns BULK_EDIT_TOO_MANY_IDS, which we surface inline.
*/
async function editAllMatching() {
if (editingAll) return;
editingAll = true;
editAllError = null;
try {
const params = buildSearchParams({
q: data.q || '',
from: data.from || '',
to: data.to || '',
senderId: data.senderId || '',
receiverId: data.receiverId || '',
tags: data.tags || [],
sort: '',
dir: '',
tagQ: data.tagQ || '',
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
});
params.delete('sort');
params.delete('dir');
const res = await fetch(`/api/documents/ids?${params.toString()}`);
if (!res.ok) {
const backend = await parseBackendError(res);
editAllError = getErrorMessage(backend?.code);
return;
}
const ids: string[] = await res.json();
bulkSelectionStore.setAll(ids);
await goto('/documents/bulk-edit');
} catch {
editAllError = m.bulk_edit_all_x_failed();
} finally {
editingAll = false;
}
}
// Keep local filter state in sync with server data after navigation completes.
// Guard q: skip overwrite while the user is actively typing.
$effect(() => {
@@ -159,7 +206,13 @@ $effect(() => {
<title>{m.nav_documents()} Familienarchiv</title>
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
sticky bar does not occlude the last document row or the pagination
controls (WCAG 1.4.10 / 2.4.7). -->
<main
class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8"
class:pb-32={bulkSelectionStore.size > 0 && data.canWrite}
>
<h1 class="sr-only">{m.nav_documents()}</h1>
<SearchFilterBar
@@ -181,9 +234,54 @@ $effect(() => {
onblur={() => (qFocused = false)}
/>
<div class="mb-3 flex items-center justify-between gap-4">
<p class="font-sans text-base text-ink-2">
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
</p>
{#if data.canWrite}
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-4">
{#if data.totalElements > 0}
<button
type="button"
onclick={editAllMatching}
disabled={editingAll}
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
data-testid="bulk-edit-all-x"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.bulk_edit_all_x({ count: data.totalElements })}
</button>
{/if}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
</div>
{#if editAllError}
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
{editAllError}
</p>
{/if}
</div>
{/if}
</div>
<DocumentList
items={data.items}
total={data.totalElements}
q={data.q}
canWrite={data.canWrite}
error={data.error}
@@ -192,3 +290,5 @@ $effect(() => {
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
</main>
<BulkSelectionBar canWrite={data.canWrite} />

View File

@@ -13,9 +13,12 @@ import { getErrorMessage } from '$lib/errors';
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
let { data } = $props();
const { confirm } = getConfirmService();
const doc = $derived(data.document);
const canWrite = $derived(data.canWrite ?? false);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
@@ -105,6 +108,26 @@ async function deleteBlock(blockId: string) {
annotationReloadKey++;
}
async function handleAnnotationDeleteRequest(annotationId: string) {
const confirmed = await confirm({
title: m.transcription_block_delete_confirm(),
destructive: true
});
if (!confirmed) return;
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
if (block) {
await deleteBlock(block.id);
} else {
// Annotation has no linked block — delete the annotation directly
const res = await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete annotation failed');
annotationReloadKey++;
}
}
async function reviewToggle(blockId: string) {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
method: 'PUT'
@@ -114,6 +137,18 @@ async function reviewToggle(blockId: string) {
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
}
async function markAllReviewed() {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, {
method: 'PUT'
});
if (!res.ok) return;
const updated = await res.json();
for (const b of updated) {
const existing = transcriptionBlocks.find((x) => x.id === b.id);
if (existing) existing.reviewed = b.reviewed;
}
}
async function toggleTrainingLabel(label: string, enrolled: boolean) {
const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
method: 'PATCH',
@@ -381,6 +416,7 @@ onMount(() => {
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
/>
</div>
@@ -477,6 +513,7 @@ onMount(() => {
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
onReviewToggle={reviewToggle}
onMarkAllReviewed={markAllReviewed}
onTriggerOcr={triggerOcr}
onToggleTrainingLabel={toggleTrainingLabel}
/>

View File

@@ -85,7 +85,8 @@ export const actions = {
if (doc.title) formData.set('title', doc.title);
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
if (doc.location) formData.set('location', doc.location);
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
if (doc.archiveBox) formData.set('archiveBox', doc.archiveBox);
if (doc.archiveFolder) formData.set('archiveFolder', doc.archiveFolder);
if (doc.transcription) formData.set('transcription', doc.transcription);
if (doc.summary) formData.set('summary', doc.summary);
if (doc.sender?.id) formData.set('senderId', doc.sender.id);

View File

@@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
export async function load({ locals }: { locals: App.Locals }) {
// Defensive: a UserGroup row with NULL permissions returns undefined here
// rather than throwing on .includes() — treat that as "not WRITE_ALL".
const canWrite =
locals.user?.groups?.some(
(g: { permissions?: string[] }) => g.permissions?.includes('WRITE_ALL') ?? false
) ?? false;
if (!canWrite) throw redirect(303, '/documents');
return { canWrite };
}

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import BulkDocumentEditLayout, {
type BulkEditEntry
} from '$lib/components/document/BulkDocumentEditLayout.svelte';
import { getErrorMessage, parseBackendError } from '$lib/errors';
import { m } from '$lib/paraglide/messages.js';
let entries = $state<BulkEditEntry[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const ids = Array.from(bulkSelectionStore.ids);
if (ids.length === 0) {
// Skip the loading flash on the empty-store redirect path — the user
// is bouncing back to /documents in the next tick.
loading = false;
await goto('/documents');
return;
}
try {
const res = await fetch('/api/documents/batch-metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
if (!res.ok) {
const backend = await parseBackendError(res);
error = getErrorMessage(backend?.code);
loading = false;
return;
}
const summaries = (await res.json()) as BulkEditEntry[];
entries = summaries;
} catch {
error = getErrorMessage(undefined);
} finally {
loading = false;
}
});
</script>
<svelte:head>
<title>{m.bulk_edit_title()} Familienarchiv</title>
</svelte:head>
{#if loading}
<div
class="flex h-full items-center justify-center gap-3 p-12 text-sm text-ink-2"
role="status"
aria-live="polite"
data-testid="bulk-edit-loading"
>
<svg
class="h-5 w-5 animate-spin text-ink-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>{m.bulk_edit_loading()}</span>
</div>
{:else if error}
<div
role="alert"
class="m-6 rounded-sm border border-danger/40 bg-danger/10 p-4 text-sm text-danger"
data-testid="bulk-edit-page-error"
>
{error}
</div>
{:else if entries.length > 0}
<BulkDocumentEditLayout mode="edit" initialEditEntries={entries} />
{/if}

Some files were not shown because too many files have changed in this diff Show More