Compare commits

...

339 Commits

Author SHA1 Message Date
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
Marcel
c3fac5b0ad feat(#320): guided empty state + Kurrent primer for first-time transcribers
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m24s
CI / OCR Service Tests (push) Successful in 3m11s
CI / Backend Unit Tests (push) Failing after 3m33s
- Three-step coach card replaces Transcribe panel empty state (edit mode)
- TranscribeDragDemo: 5-second SMIL animation, static final frame for prefers-reduced-motion
- HelpPopover reusable primitive with Esc/outside-click/focus-return
- (?) help chip in TranscriptionPanelHeader next to Read/Edit toggle
- Copy pass: markieren → einrahmen in transcription_next_block_cta
- New route /hilfe/transkription (prerendered, auth-required) with 5 RichtlinienRuleCard instances, 4 Klärung chips, closing card, @media print styles
- 34 new i18n keys across de/en/es
- E2E specs: transcribe-coach, richtlinien (axe + print), help-popover; reducedMotion: 'reduce' project-wide default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:42:29 +02:00
Marcel
03b180fe88 test(e2e): add transcribe-coach, richtlinien, and help-popover E2E specs; reducedMotion global default
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m29s
CI / OCR Service Tests (push) Successful in 55s
CI / Backend Unit Tests (push) Failing after 3m16s
CI / Unit & Component Tests (pull_request) Failing after 3m3s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 3m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:39:03 +02:00
Marcel
b234db0472 feat(richtlinien): add /hilfe/transkription page with RichtlinienRuleCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:28:25 +02:00
Marcel
7c3a8e7651 feat(transcribe): add HelpPopover primitive and wire (?) chip into panel header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:19:48 +02:00
Marcel
7fb9d74515 feat(transcribe): copy pass markieren→einrahmen in transcription_next_block_cta
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:08:40 +02:00
Marcel
dff203d526 feat(transcribe): wire coach into TranscriptionEditView, hide training footer when empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:06:50 +02:00
Marcel
86584a53a8 feat(transcribe): add TranscribeCoachEmptyState and TranscribeDragDemo components
New coach card replaces the icon+sentence empty state in the Transcribe
panel (edit mode). Three-step guide with 5-s SMIL drawing animation in
step 1 only. Animation freezes at the final frame when
prefers-reduced-motion is active. Footer links to Wikipedia Kurrent and
the Richtlinien page open in new tabs with visible '(öffnet in neuem Tab)'
annotations. 34 new i18n keys in de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:00:01 +02:00
Marcel
1d5219eac4 docs(specs): add Transkriptions-Richtlinien spec for #320
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 2m58s
Final UI/UX spec for the /hilfe/transkription page referenced from
the Transcribe panel coach card. Card-grid layout with per-rule
Beispiel boxes, Wikipedia info-card, "Noch in Klärung" strip, and
closing invitation. Includes impl-ref tables, Paraglide keys for
de/en/es, print styles, and Gherkin acceptance criteria.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 18:47:47 +02:00
Marcel
6e021fb23a fix(briefwechsel): repair 500 by consuming backend thumbnailUrl directly
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m46s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Failing after 2m56s
ConversationThumbnail still imported the `$lib/thumbnails` helper that
a02f6cdc deleted, so every SSR render of /briefwechsel crashed with
"Cannot find module '$lib/thumbnails'". Finish that refactor by reading
`doc.thumbnailUrl` straight off the Document DTO (same shape
DocumentThumbnail already uses), and update the spec fixtures to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:27:19 +02:00
Marcel
bdac5e42ad test(search): integration test covers paged search against real Postgres — address @saraholt
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m15s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Has been cancelled
Seeds 120 UPLOADED docs with a deterministic date spread and runs
DocumentService.searchDocuments against a Testcontainers Postgres, not
a Mockito mock. Five cases:

  1. First page returns exactly page_size items + correct totalElements
  2. Last partial page returns the tail slice (offset 100 → 20 items)
  3. Page beyond last returns empty content, totalElements still 120
  4. SENDER sort path slices in-memory + reports correct total
  5. Different pages return disjoint document id sets

Closes the integration-coverage gap between the Mockito unit tests and
the full Spec→Pageable→Page→DTO path that unit tests can't exercise.
Runs in ~87 s against the shared Testcontainers instance. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
18b88672ec fix(pagination): bound controls render as aria-hidden spans — address @leonievoss
<a aria-disabled="true"> is the documented pattern but screen readers
still announce "Previous, link, disabled" on pagination bounds — noise
users don't need because the disabled state is purely visual. Switching
to <span aria-hidden="true"> removes the bound control from the AT tree
entirely (Leonie's recommendation). Visual parity preserved via a
disabledBase Tailwind class (same layout + cursor-not-allowed + opacity-40).

Tests updated: "disabled prev/next" assertions now check for aria-hidden
and no href — the active-state href/aria-current assertions are
unchanged. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
8fa061187e refactor(documents): extract buildSearchParams — address @felixbrandt
triggerSearch (local state, filter change) and buildPageHref (server data,
page nav) were each iterating over the same ~10 filter params. Any new
filter would have had to land in two places. buildSearchParams is now the
single source of truth for which params the /documents URL understands;
both callers just pass their snapshot and an optional targetPage. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
610915b2a2 refactor(test): extract UNPAGED Pageable constant — address @felixbrandt + @saraholt
PageRequest.of(0, 10_000) was inlined at ~12 sites across DocumentServiceTest
and DocumentServiceSortTest as an "effectively unpaged" sentinel for tests
that don't care about paging. Extracted to a named constant on each class
so the intent is visible at each callsite and we don't risk copy-paste
drift of the magic number. No behaviour change. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
78ac5d663d feat(documents): paginate search with a Pagination control
Frontend side of the /documents pagination work. The page.server.ts load
reads ?page= from the URL, forwards page+size=50 to the backend, and
exposes the new totalElements/pageNumber/pageSize/totalPages fields on
`data`. +page.svelte renders a <Pagination> component below the result
list; buildPageHref preserves every filter param and only updates page.
The existing triggerSearch debounce flow intentionally drops `page`
when any filter changes, so filter edits reset to page 0 automatically.

<Pagination> uses plain <a href> links (not goto) so SvelteKit's default
scroll restoration scrolls new pages to the top — the expected senior-UX
behaviour. Decorative chevrons wrapped in aria-hidden spans, 44px touch
targets, focus-visible ring, stacks vertically under 640px. The control
hides itself when totalPages ≤ 1.

Test coverage: 9 cases on Pagination (label, aria-current, prev/next
enable/disable, makeHref invocation, decorative chevron, touch target),
plus a filter-reset assertion on +page.svelte (page 5 → edit q →
goto URL must drop page=). Adds i18n keys in de/en/es. Manual edit to
api.ts pending a post-merge npm run generate:api against a rebuilt
dev backend. (#315)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
826c0827dc test(search): lock pagination behaviour and @Validated rejection
Adds 5 dedicated controller cases — paging fields exposed on the JSON,
rejections for size>100 / size<1 / page<0 / page>100000, and a
captor assertion that the built PageRequest is forwarded to the service.
The size>100 case is the load-bearing guard on @Validated at
DocumentController — removing the annotation silently reopens the DoS
window this PR is meant to close.

Adds 5 service cases — fast path uses findAll(Spec, Pageable) (not Sort),
propagates page+size to the DB, carries totalElements/totalPages/
pageNumber/pageSize back on the result, and for SENDER sort slices in
memory and reports the pre-slice total. Page-beyond-last returns empty
content with a correct totalElements (JPA edge case). (#315)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
7a75ffed76 feat(search): DocumentService.searchDocuments takes Pageable and slices
Fast path (DATE/TITLE/UPLOAD_DATE) pushes sort + paging into the DB via
findAll(Specification, PageRequest) and enriches only the returned slice
— 30× cheaper than enriching all 1500 matches when the user is only
going to see 50. In-memory sort paths (SENDER/RECEIVER/RELEVANCE) keep
their LEFT JOIN-friendly sort but now slice in-memory too, so enrichment
still runs against the page slice only.

Controller passes PageRequest.of(page, size) built from @RequestParam
values. Plan-level "add @Validated" prerequisite comes in the next commit.

All existing tests updated mechanically to pass a pageable argument
(PageRequest.of(0, 10_000) as an "effectively unpaged" sentinel). Stubs
that previously matched findAll(Specification, Sort) for the fast path
now match findAll(Specification, Pageable) with PageImpl<>.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
1299bd5938 feat(search-result): extend DocumentSearchResult with pageNumber/pageSize/totalPages
Rename `total` → `totalElements` for Spring-Page parity and add three new
required paging fields: pageNumber, pageSize, totalPages. Adds a `paged(
slice, pageable, totalElements)` factory alongside the existing single-page
`of(list)` shortcut. Enables offset pagination of /documents search (#315).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
8f28a99e00 docs(specs): bulk upload split-panel spec + concept exploration
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 28s
CI / Backend Unit Tests (push) Failing after 2m51s
Adds two specs for extending issue #294 with bulk uploads:

- bulk-upload-concepts.html — three concepts (stack, split-panel
  with file switcher, progressive accordion) with a decision
  matrix and the Concept B recommendation.
- bulk-upload-split-panel-spec.html — refined final spec for
  Concept B. Covers all three states (N=0 empty · N=1 single ·
  N≥2 multi) across 320 / 375 / 768 / 1280 viewports in both
  light and dark mode, using the real tokens from layout.css.
  Includes impl-ref tables for every new surface, Paraglide keys
  in de/en/es, component tree, and backend contract.

The polymorphic-state model means /documents/new is a single
route: N=1 is byte-identical to #294, N=0 shows a whole-panel
drop zone with bulk-first copy, N≥2 grows a file-switcher strip
under the PDF preview plus a two-card form split.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 10:31:42 +02:00
Marcel
7007491d8c style(dashboard): address @leonievoss — scale fallback icon to match larger container
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m26s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m4s
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 3m17s
h-16 w-16 looked undersized in the 180×252 strip container (~25% of
the height). h-24 w-24 gives ~38% visual weight, matching the ratio
DocumentThumbnail uses for its lg (120×168) fallback (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
629f0183f7 test(document): address @saraholt — lock JSON wire contract for thumbnailUrl
Prior coverage only exercised getThumbnailUrl() as a Java method call.
The new case serialises via ObjectMapper and asserts the resulting JSON
contains "thumbnailUrl":"..." so we catch silent breakages in the wire
contract (getter rename, @JsonIgnore, visibility drop) — not just
regressions in the method's return value (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
72cd6f5bbc feat(dashboard): fall back to document-text heroicon when no thumbnail yet
Uses the same heroicon as DocumentThumbnail so the "no thumbnail yet"
signal reads identically across the app: one shape, one meaning. The
parchment SVG still lives on in the fully-empty state (no resume doc
at all), where it represents a different thing — we removed it only
from the "document exists, thumbnail not generated yet" branch (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
1d44bbb1bd feat(dashboard): render real document thumbnail in resume strip
Replaces the generic parchment SVG placeholder with an <img> pointing at
the backend's thumbnail endpoint when the document has one. The 180×252
container matches DocumentThumbnail's 5:7 A4 convention so the
dashboard tile sits visually next to the list/person-sublist tiles
instead of looking squatter than they do. dark:mix-blend-multiply keeps
paper scans from glaring on a dark page background (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
a02f6cdcd7 refactor(thumbnails): drop frontend URL-builder now that backend owns the convention
The helper had a single consumer (DocumentThumbnail) and its only job
was to compose what the backend's Document.getThumbnailUrl() now
produces. Deleting it locks the single-source-of-truth invariant —
there is no longer a way to build a thumbnail URL on the client (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
817749889a refactor(document-thumbnail): read doc.thumbnailUrl instead of composing locally
The backend now exposes thumbnailUrl as a serialised computed property
on Document, so the component drops its dependency on the frontend
URL-builder. PersonDocumentList's inline Doc prop type follows the
same shift (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
a8b9133b80 chore(api): regenerate Document type with thumbnailUrl field
Reflects the new @JsonProperty getter on Document. Kept as a minimal
manual edit rather than a full regen because the running dev backend
belongs to the main workspace and swapping JARs there would be a
side effect on a parallel worktree's state. `npm run generate:api`
will converge on the same shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
510ab1d2d5 feat(dashboard): populate resume thumbnailUrl from Document
DashboardService now reads the URL from the Document's computed getter
instead of passing null, so the resume strip can display the real
thumbnail of whatever the user was last working on (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
ad999c47ea feat(document): expose thumbnailUrl to JSON serialisation
@JsonProperty makes the computed getter part of every Document response
Jackson produces, so any DTO returning a Document automatically carries
the thumbnail URL without per-controller plumbing. The accompanying
comment warns future readers that the cache-buster is load-bearing
for the endpoint's `immutable` cache header (CWE-525) (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
9862a51ac7 feat(document): getThumbnailUrl appends URL-encoded timestamp as cache-buster
Matches the shape the frontend previously built via
encodeURIComponent(thumbnailGeneratedAt), so the backend is now the
single source of truth for the thumbnail URL convention (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
df260d5c64 feat(document): getThumbnailUrl composes /api/documents/{id}/thumbnail when key present
The no-cache-buster branch covers documents whose thumbnail key is set
but whose thumbnailGeneratedAt is still null — which only happens in
the narrow window between the key being persisted and the async worker
stamping the timestamp (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
096f66eb15 test(document): getThumbnailUrl returns null when thumbnailKey is null
First TDD step for centralising the thumbnail URL convention on the
Document entity (#309). Adds a stub getter returning null and a test
that locks the "no key → no URL" branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
0b33f323ee feat(briefwechsel): restore direction arrow next to row title
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m45s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m56s
PR #311 dropped the right/left arrow icons that signalled whether a
letter was sent or received. Readers who don't decode the colored
left border (new users, color-blind users, users at a glance) had
no visual cue for direction. Restore a 20×20 arrow inline with the
title — right-arrow for outgoing, left-arrow for incoming — kept
decorative (aria-hidden) since the aria-label already announces
"Gesendet:" / "Empfangen:".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:57:57 +02:00
Marcel
334b624063 feat(briefwechsel): bump row typography and drop relative-year chip
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m50s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Has been cancelled
The 168px-tall thumbnail tile was dominating rows where the text
column only rendered at text-xs / text-sm — visually the right
column sat half-empty. Three changes:

- Title: text-sm → text-lg
- Summary: text-sm → text-base
- Meta + tag chips: text-xs → text-sm

And remove the "vor N Jahren" chip entirely. The documentDate
in the meta row already carries the temporal context and the
chip was adding visual noise without new information.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:52:23 +02:00
Marcel
503ce49ef7 refactor(briefwechsel): TagChipList defaults max to 3
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m56s
Makes `max` an optional prop with default 3 — the common row-layout
case doesn't need to name the cap explicitly. ThumbnailRow's callsite
drops to `<TagChipList tags={doc.tags ?? []} />`, consistent with how
other shared components in $lib/components expose sensible defaults.

Refs #305
Fixes @leonievoss round-2 follow-up from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
f5a30c71b7 i18n(briefwechsel): ThumbnailRow direction label via Paraglide
Adds row_direction_sent / row_direction_received keys across the
three locale files (de: Gesendet/Empfangen, en: Sent/Received, es:
Enviada/Recibida) and routes ThumbnailRow's directionLabel through
Paraglide. An English or Spanish screen-reader user now hears
"Sent:" / "Enviada:" in their language, matching the DistributionBar
i18n pass.

Refs #305
Fixes @leonievoss round-2 follow-up from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
720f90299a refactor(e2e): visual spec shares seedBilateralPair + asserts person-bar
Rewires briefwechsel-rows.visual.spec.ts against the shared fixture
(seedBilateralPair + cleanupBilateralPair), adds afterAll cleanup,
and folds the conv-person-bar visibility gate into openBilateral()
so both the structural test and the snapshot block fail loudly on
a hero-state regression — matching the a11y spec's safety net.

Refs #305
Fixes @saraholt follow-ups 1 + 2 + 3 from PR round-2 review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
0e988a9d42 refactor(e2e): extract seedBilateralPair fixture + afterAll cleanup
Lifts the three-API-call seeding (create sender, create receiver,
create document) out of briefwechsel-a11y.spec.ts and into a
dedicated fixtures module. The spec now calls seedBilateralPair()
in beforeAll and cleanupBilateralPair() in afterAll so the test
DB doesn't accrue seeded rows across reruns.

Two caveats captured in the helper docstring: the backend has no
person-delete endpoint (only the document is purged), and the
timestamped last names make leftover persons collision-free.

Refs #305
Fixes @saraholt follow-up 1 + 2 from PR round-2 review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
8cb179a8a1 test(briefwechsel): visual spec seeds bilateral pair and asserts row structure
Extends the seeding pattern from the a11y spec: beforeAll creates two
persons + one document so the page renders the row layout. The
structural test now asserts the ConversationThumbnail tile AND the
DistributionBar are present — a regression that drops to the hero
or breaks the row wiring fails here instead of silently passing a
hero-state check.

Snapshot block stays gated on VISUAL=1 (baselines captured during
review against a seeded backend) so the structural coverage ships
immediately and the pixel-diff coverage ships once baselines land.

Refs #305
Fixes @saraholt blocker 2 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
05c1bf750a test(briefwechsel): a11y spec seeds bilateral pair and axes the row layout
The previous version navigated to /briefwechsel with no params, which
renders the hero state — axe-core scanned the hero, not the new
ThumbnailRow / ConversationThumbnail / DistributionBar. This commit
seeds two persons + one document via the API in beforeAll, then
drives the URL with ?senderId=X&receiverId=Y so each of the
36 test runs (3 viewports × 2 themes × 2 assertions) actually scans
the intended DOM. Also asserts that conv-person-bar is visible first,
so a regression that drops the page back to hero fails explicitly
rather than silently passing an empty sweep.

Refs #305
Fixes @saraholt blocker 1 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
a7ab5e6e69 refactor(briefwechsel): extract TagChipList from ThumbnailRow
Lifts the three-chip-plus-"+N" tag row out of ThumbnailRow into a
standalone TagChipList component so the chip cap + overflow policy
lives in one place and can be reused on other surfaces (document
detail header is a candidate). ThumbnailRow drops from 110 to ~90
lines and no longer owns tag-slicing logic — it just asks for the
list with max=3.

Behavior is byte-identical: same data-testid, same max cap, same
"+N" overflow indicator. All ThumbnailRow row-level tag tests
continue to pass against the new composition.

Refs #305
Fixes @felixbrandt suggestion 1 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
24b2dc0460 refactor(thumbnails): pack key + aspect + pageCount into ThumbnailResult
persistThumbnailMetadata was a four-arg method signature that mixed
three conceptually related values. Wrapping them in a private
ThumbnailResult record drops the signature to (Document, result),
mirrors the existing SourcePreview record one step earlier in the
pipeline, and keeps generate() reading as a narrative of small
named outputs rather than positional arguments.

Refs #305
Fixes @felixbrandt suggestion 2 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
9ecf7f4dfc refactor(briefwechsel): ThumbnailRow captures now at prop binding
Defaults `now` in $props() destructure so each row instance freezes
its reference time at mount, instead of calling new Date() inside
the $derived every reactivity tick. No behavioural change — the
date math is stable across re-renders for a given row — but drops
the nullish-coalesce dance and is cleaner under Storybook-style
testing where a deterministic `now` is injected.

Refs #305
Fixes @felixbrandt suggestion 3 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
01bfc59849 test(briefwechsel): lock future-date relative-year hiding at the row layer
relativeYearsDe already returns "" for future dates (covered in its
own spec), but the integration wiring inside ThumbnailRow was
untested. Adds a regression that a doc with documentDate in the
future produces no "vor N Jahren" or "vor weniger als 1 Jahr" chip.

Refs #305
Fixes @saraholt concern 5 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
03616f0728 test(briefwechsel): makePerson factory + per-row tile assertion
Consolidates the hansPerson / annaPerson fixture into a makePerson()
factory matching the makeDoc convention, adds an assertion that
the bilateral list renders one ConversationThumbnail tile per
document (catches a broken {#each} keying wired around the
DistributionBar), and decouples the DistributionBar aria-label
assertion from the German locale now that i18n lands via Paraglide.

Refs #305
Fixes @saraholt concerns 3 + 4 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
7090f9a0e0 feat(briefwechsel): ConversationThumbnail page badge legible at small sizes
Bumps the multi-page badge from text-xs (12px) / px-1.5 py-0.5 to
text-sm (14px) / px-2 py-1. Meets senior-legibility on a 320px phone
without crowding the 120-wide tile — the badge stays tucked in the
top-right corner.

Refs #305
Fixes @leonievoss senior-accessibility concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
d4617a96d1 i18n(briefwechsel): DistributionBar reads text + aria-label via Paraglide
Drops the hardcoded German strings ("Briefverteilung in diesem Zeitraum",
"{n} von {name}") and routes every visible + assistive-tech string
through dist_bar_aria and dist_bar_segment message keys. An English
or Spanish user now sees "from" / "de" instead of "von" both on
screen and in the aria-label their screen reader announces.

Refs #305
Fixes @leonievoss i18n concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
b9dda9a938 feat(briefwechsel): ThumbnailRow aria-label leads with Gesendet/Empfangen
Without this prefix, a color-blind user or screen-reader user has no
indication of correspondence direction — the colored left border is
information but not announced, and the arrow glyphs were removed in
the earlier layout pass. Prepending "Gesendet:" or "Empfangen:" to
the aria-label gives assistive-tech users the direction first so the
row identity is unambiguous even without color perception.

Refs #305
Fixes @leonievoss WCAG 1.4.1 concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
d6b1949c84 docs(adr): ADR-005 thumbnailAspect + pageCount alongside the thumbnail
Captures the reasoning behind persisting two scalar columns on
documents rather than deriving aspect client-side or standing up a
thumbnail_metadata table. Also documents the 1.1 landscape threshold,
the null-during-rollout state, and the ordering invariants inside
ThumbnailService.generate().

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
c16a9ca602 test(briefwechsel): axe sweep at 3 viewports x 2 themes
Adds a dedicated axe-core sweep for /briefwechsel so contrast or
semantic regressions on the new row layout fail independently of
the catch-all accessibility suite. Scoped to the main landmark so
shared chrome violations (if any) aren't double-reported.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
30e301830a test(briefwechsel): scaffold visual-regression spec for row layout
Adds a Playwright spec gated on VISUAL=1 with one snapshot per
(mobile/tablet/desktop × light/dark) = 6 baselines. Snapshots stay
skipped in CI until the baseline set is captured and committed —
running `playwright test --update-snapshots briefwechsel-rows`
against a seeded backend generates them.

Structural check runs unconditionally so the file is wired into CI
today rather than waiting for the baseline capture step.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
4b893b4808 test(briefwechsel): cover DistributionBar and fix Person fixture shape
Adds two new assertions for the extracted DistributionBar — it must
appear in bilateral mode and stay hidden in single-person mode — and
repairs the shared makeDoc fixture: the embedded Person now carries
personType + displayName so the fixture matches the regenerated
Document schema without TypeScript complaints.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
df681be626 refactor(briefwechsel): ConversationTimeline renders ThumbnailRow per letter
Drops the inline row markup, arrow icons, status-dot helper, and the
otherPartyName helper that only fed it. Each visible row is now a
ThumbnailRow, which owns its own aria-label, border color, meta and
tag rendering. The year-divider and "new document" footer are
untouched — they were always intended to stay as timeline chrome.

Also widens the documents prop shape to include the summary, tags
and thumbnail metadata that ThumbnailRow consumes; the backend
already returns these fields via the Document schema so no server
change was required.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
cc118ffb16 feat(briefwechsel): add ThumbnailRow for the new correspondence row layout
Combines ConversationThumbnail with a quote-styled summary, truncated
meta line, and up to three tag chips (the rest collapsed into "+N").
The colored left border tells a reader at a glance whether this
letter left or entered the perspective person's mailbox — replacing
the previous status dot + script-type icons that were too busy for
the list view. Relative-year label ("vor 76 Jahren") is derived from
documentDate so the list carries temporal context without a full
date column.

Rendering rules:
- title falls back to originalFilename when empty
- summary uses a text expression, never {@html}, so inline markup
  in the summary field is escaped (XSS regression test locks this)
- focus-visible outline + focus-within hover keep keyboard-only
  users in sync with mouse hover feedback
- aria-label always pairs title with the formatted date so screen
  readers hear both identifiers

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
407bfbd5f1 feat(briefwechsel): add ConversationThumbnail with aspect + page badge
Reads thumbnailAspect from the backend and swaps between a 120×168
portrait tile and a 168×120 landscape tile so postcards and photos
don't get cropped into a portrait frame. Shows a page-count badge
top-right for multi-page PDFs, and a pulsing skeleton while the
async thumbnail job hasn't run yet. URL assembly goes through the
existing thumbnailUrl helper so cache-busting stays consistent
with DocumentThumbnail.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
a52d481a8e feat(relativeTime): add relativeYearsDe helper for historical letter dates
The correspondence timeline labels each row with its distance from today
("vor 86 Jahren"). Uses calendar-field math so the anniversary day
flips exactly — an ms-based 365.25d average misses by a day on leap
years. Invalid / future dates return "" so the caller can hide the
label rather than print "vor 0 Jahren".

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
70d813ee70 refactor(briefwechsel): ConversationTimeline consumes DistributionBar
Drops the inline bilateral-distribution markup and the short-name /
percentage helpers that only existed to feed it. ConversationTimeline
now hands senderName, receiverName, and the two counts to the shared
component and lets it own the rendering.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
d99f4544d2 refactor(briefwechsel): extract bilateral DistributionBar component
Lifts the inline distribution bar out of ConversationTimeline so the
same two-tone ratio widget can be reused on other bilateral surfaces
(e.g. the person detail page). Markup/styling is byte-identical to
the inline version; only the prop interface is new.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
22ce705bb0 feat(api): surface thumbnailAspect + pageCount on the Document type
Mirrors the backend entity additions so the frontend row components
can consume the aspect (portrait vs landscape tile) and the page count
(badge on the thumbnail) without any runtime guessing.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
e6d55e47b1 feat(thumbnails): persist pageCount from PDDocument / 1 for images
Groups the first-page BufferedImage and the source's total page count
into a SourcePreview record so both values travel through generate()
together. PDFs get pdf.getNumberOfPages(); image uploads always get 1
(a scan is one page from the user's perspective). The page badge on
the thumbnail row uses this value to show "1 / N" for multi-page
letters without a separate round-trip.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
b48533be26 feat(thumbnails): persist thumbnailAspect from source image dimensions
Computes aspect at generate-time from the loaded BufferedImage: w/h
above 1.1 → LANDSCAPE, otherwise PORTRAIT. The threshold keeps
near-square A4 scans in the portrait tile (ratio ≈ 1.0) rather than
flipping to landscape on a rounding error. Also hardens the pipeline
with an explicit dimension guard so width=0 / height=0 edge cases fail
cleanly instead of dividing by zero when the aspect is computed.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
7fc517b787 test(thumbnails): lock corrupt-image + corrupt-pdf failure paths
Both cases already return FAILED via the existing catch-Exception blocks
in readSourceImage. Pinning the behavior with regression tests before
thumbnailAspect and pageCount computation is added, so a future
refactor that removes the safety net is caught at compile/test time.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
8ac996f6b2 feat(documents): expose thumbnailAspect + pageCount on Document entity
Adds ThumbnailAspect enum (PORTRAIT | LANDSCAPE) and maps the two
nullable columns from V53 as JPA fields so ThumbnailService can
populate them and the API can return them unchanged to the frontend.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
55557047de feat(documents): V53 add thumbnail_aspect + page_count columns
Adds two nullable metadata columns to documents, populated by
ThumbnailService when it generates the JPEG preview. Both remain null
until the existing admin backfill endpoint reruns the service.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
94e976bae3 docs(specs): rework person dashboard spec around data reality
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m15s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m1s
The archive has ~4 persons over 100 letters and ~90% with five or
fewer — the original spec's 851-letter default fit no one.

Redesign introduces three tiers gated on letterCount (Compact ≤ 5,
Standard 6–49, Rich ≥ 50) sharing one dashboard block: navy header +
4-cell stats strip at every non-Empty tier, with Standard appending
direction bar + top correspondents and Rich further appending
histogram + top locations + tag cloud. Backend skips expensive
aggregations for non-Rich persons; histogram and tag cloud ship
lazy-loaded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:44:42 +02:00
Marcel
23cf88856e fix(ocr): guard Kraken block extraction against missing boundary/baseline
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 2m51s
extract_page_blocks() walked `record.boundary` and `record.baseline`
unconditionally, so a record that arrived without either (malformed
kraken output, or a MagicMock in tests that iterates to nothing)
crashed with "min() arg is an empty sequence".

Coerce both attributes through list(), require at least 3 points for
the polygon path, fall back to the baseline path when the polygon is
missing, and skip the record entirely when neither is usable —
emitting no block is safer than emitting one with garbage coordinates.

The test helper now sets `boundary` and `baseline` explicitly to
mirror real Kraken 7.0 records (and so the happy-path test exercises
the polygon branch). A new regression test covers the skip path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:33:03 +02:00
Marcel
1f7b712dd0 fix(ocr): accept sender_model_path in Surya engine so non-Kurrent OCR works
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m36s
CI / OCR Service Tests (push) Successful in 33s
CI / Backend Unit Tests (push) Has started running
main.py unifies the call to both engines and always passes
`sender_model_path` (None for non-Kurrent scripts). Surya's
extract_region_text / extract_page_blocks accepted one fewer positional
arg than Kraken's, so every guided-OCR run on a TYPEWRITER or
HANDWRITING_LATIN document raised "takes 5 positional arguments but 6
were given" and the stream returned 0 blocks / 1 skipped page.

Add an ignored `sender_model_path` kwarg to both Surya functions so the
signatures match Kraken's, and guard the regression with two signature
tests in test_engines.py that compare both engines' parameter lists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:28:25 +02:00
Marcel
90f111fcb1 style(documents): bump right-column font-size from xs to sm in list rows
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m32s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m50s
The 12px text felt cramped next to the larger 120×168 thumbnail. Lift
the date / VON / AN / progress label to 14px so the row reads
comfortably without changing the width or the row height.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:17:30 +02:00
Marcel
bca27898f7 fix(documents): tag click no longer navigates to document detail page
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m38s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Failing after 2m55s
Nesting the tag <button> inside the row's <a href="…"> made the browser
treat any click on the button as a click on the anchor, sending the
user to the document detail page even though the tag handler called
goto() with the tag-filter URL. e.stopPropagation() doesn't cancel
the anchor's default navigation.

Refactor to the stretched-link pattern: the row-wide anchor sits as an
overlay (`absolute inset-0 z-0`) and the content wrapper sits above it
(`relative z-10` + `pointer-events-none`). Tag buttons re-enable
pointer events with `pointer-events-auto`, so they're true siblings of
the anchor and receive their own clicks. Empty content areas pass
through to the anchor for whole-row navigation.

The vitest-browser client project doesn't load Tailwind CSS, so the
z-index has no effect there and Playwright's coordinate-based click
hits the anchor instead of the button. Trigger the click directly on
the button DOM element in the unit test (with a comment explaining the
test-env constraint); the actual user-facing behavior is verified via
playwright against the running dev server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:10:20 +02:00
Marcel
a7efb0044c feat(documents): rebalance list row — summary + archive chips, restored sender/receiver
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m43s
CI / OCR Service Tests (push) Successful in 33s
CI / Backend Unit Tests (push) Failing after 2m57s
Refill the columns that went visually empty after the previous dedup
commit (`fc0fc57`):

- Middle column gains the document `summary` (line-clamp-2, italic,
  with `summaryOffsets` highlighting — the backend already populates
  the offsets, the frontend just wasn't rendering them) and a row of
  thin neutral chips for `archiveBox`, `archiveFolder`, and `location`
  (~99% of docs in the corpus carry these). Chips are desktop-only
  and skip empty values.
- Right column restores `VON sender` and `AN receivers`, now with
  `<mark>` highlighting that the previous right-column copy lacked,
  so search matches stay visible there.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 08:44:49 +02:00
Marcel
fc0fc57409 refactor(documents): drop duplicate sender/receiver in list rows
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 2m52s
The desktop document-list row showed sender/receiver twice — once
side-by-side in the middle column and again stacked in the right
column. Stack the middle-column block vertically (the side-by-side
grid wasted horizontal space and competed with the larger thumbnail)
and remove the now-redundant copy from the right column.

The middle-column block keeps the search-match highlighting, which the
right-column copy never had.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 08:19:46 +02:00
Marcel
6aae159698 feat(documents): enlarge thumbnail in document-list rows
Add a `size` prop to DocumentThumbnail (default `sm` keeps the existing
60×84 tile used in person sublists; new `lg` is 120×168) and use `lg`
for the main document-list row, where the previous tile occupied less
than half of the row's vertical space.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 08:18:50 +02:00
Marcel
31713c324b fix(proxy): block proxy-connection hop-by-hop header from client responses
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 3m0s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:12:21 +02:00
Marcel
464b8d35d3 fix(proxy): enforce body size limit on actual byteLength, not just Content-Length header
Chunked requests omit Content-Length entirely. The previous guard
only checked the header and was bypassed. Now the body is buffered
first and its byteLength is checked, catching both cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:12:21 +02:00
Marcel
e1ae299326 test(proxy): add PATCH forwarding and absent Content-Length coverage
Closes the two untested code paths flagged in review:
- PATCH method routes to backend with correct URL
- Requests without Content-Length header pass through (NaN > n = false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:12:21 +02:00
Marcel
253c539f18 refactor(proxy): rename +server.spec.ts → proxy.spec.ts
Eliminates the Vitest "Files prefixed with + are reserved" warning
on every test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:12:21 +02:00
Marcel
ff0bb89297 refactor(autosave): rename flushViaBeacon → flushOnUnload; add void to fire-and-forget fetch
The sendBeacon name was misleading after switching to keepalive fetch.
Also adds a test to confirm flush is a no-op when pendingTexts is empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:12:21 +02:00
Marcel
c9dd3f8e78 feat(proxy): add 1MB body guard and full proxy test suite
Blocks requests with Content-Length > 1 048 576 bytes with 413.
Tests cover security guards, body limit, and response forwarding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:12:21 +02:00
Marcel
e1304b6512 fix(transcription): replace sendBeacon with fetch keepalive; add catch-all API proxy
sendBeacon always sends POST, but the backend expects PUT for block updates, so
saves were silently dropped on page unload.  Replace with fetch({ keepalive: true,
method: 'PUT' }) which survives navigation and uses the correct HTTP method.

Add a catch-all SvelteKit server route at /api/[...path] so all client-side API
calls work in production (without the Vite dev proxy).  More-specific routes
(/api/persons, /api/tags, /api/documents/[id]/file) keep precedence.

Closes #204

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:12:21 +02:00
Marcel
b6bfb9148e fix(frontend): use generic document icon for thumbnail fallback
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m48s
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
Addresses @leonievoss and @felixbrandt — fix(ui): "the PDF icon
misleads for image documents" and "swap for a neutral file icon".

The fallback now shows a generic document-text glyph (page outline +
three text lines) instead of the PDF-specific icon with the folded
corner. Applies equally well to PDFs, JPEG/PNG scans, and TIFF
documents — all of which can land in the fallback path.

Also bumped the icon from h-6/w-6 to h-8/w-8 — the previous 24px
glyph looked sparse inside the 60×84 tile (Leonie, post-merge
iteration point #2).

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:03:19 +02:00
Marcel
f0f9753c42 refactor(backend): split ThumbnailService.generate into stages with distinct logs
Addresses @felixbrandt — fix(backend): "the two try blocks in generate()
overlap — a save failure logs 'generation failed' even though the
thumbnail is already in S3 as an orphan".

generate() now orchestrates four stages, each in its own try+log:
  readSourceImage / encodeThumbnail / uploadToStorage / persistThumbnailMetadata

persistThumbnailMetadata emits the distinct "orphaned in storage as <key>"
log line so an operator can see database-side failures after the upload
completed. The deterministic key ensures the next run overwrites cleanly,
so the orphan is self-healing.

Also extracts THUMBNAIL_KEY_PREFIX/SUFFIX constants with a comment
explaining the deterministic-overwrite contract.

Adds test: generate_returnsFailed_whenPersistThrows_butUploadSucceeded.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:01:50 +02:00
Marcel
f137aa79a2 docs(adr): document layering exception and in-memory backfill state
Addresses @mkeller (Markus) — fixes(adr): "the ADR doesn't mention
in-memory BackfillStatus" and "treat this as a layering exception,
acknowledge it explicitly". Two new paragraphs under Operational caveats.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:58:36 +02:00
Marcel
39eaa10d85 docs(adr): record ADR-004 — PDFBox thumbnails stay in Spring Boot
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m45s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m57s
CI / Unit & Component Tests (pull_request) Failing after 2m37s
CI / OCR Service Tests (pull_request) Successful in 33s
CI / Backend Unit Tests (pull_request) Failing after 2m50s
Captures why thumbnails render in-process rather than being delegated
to ocr-service. Prevents a future reviewer from rehashing the decision
or moving it to the Python side without knowing the trade-offs.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:43:27 +02:00
Marcel
abbb7c798f test(e2e): cover admin thumbnail generation card + a11y
- admin.spec: click 'Thumbnails erzeugen', wait for status DONE
  within 30s, screenshot the success message
- accessibility.spec: /admin/system joins the page list so the
  thumbnail card is checked in light, system-dark, and manual-dark
  axe-core runs

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:41:58 +02:00
Marcel
7bb3800490 feat(frontend): add admin card to generate thumbnails with polling
Fourth card on /admin/system mirrors the mass-import pattern:
- POST /api/admin/generate-thumbnails to trigger
- 2000 ms polling on /api/admin/thumbnail-status while RUNNING
- processed / skipped / failed counters in the DONE message
- standalone pollInterval so import and thumbnail polling don't
  interfere with each other

Paraglide keys added in de/en/es, mirroring admin_system_import_*.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:39:47 +02:00
Marcel
04ebd2a5bd feat(frontend): render DocumentThumbnail in DocumentRow and PersonDocumentList
Home search rows and person detail sidebars now show the real
first-page preview when one exists, falling back to the PDF icon
for documents the backfill hasn't processed yet. The old `variant`
prop on PersonDocumentList is removed — it tinted the icon
differently for sent vs received, which no longer applies with a
uniform thumbnail tile.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:36:20 +02:00
Marcel
be184d8faf feat(frontend): add DocumentThumbnail shared 60x84 tile component
Renders the document thumbnail with object-cover + object-top so
letter salutations stay visible, empty alt (title nearby is the
accessible name), loading=lazy, decoding=async, and dark:mix-blend-multiply
for dark mode. Falls back to a PDF icon when thumbnailKey is null —
legacy documents, unsupported content types, or transient failures
all land here.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:31:35 +02:00
Marcel
0c95797242 feat(frontend): add thumbnailUrl helper with cache-bust param
Pure function returning /api/documents/{id}/thumbnail?v=<timestamp>
or null when thumbnailKey is missing. The encoded timestamp changes
whenever the backend regenerates a thumbnail (file replace),
invalidating browser caches despite the immutable Cache-Control.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:30:43 +02:00
Marcel
75ae4b6a02 feat(frontend): add thumbnailKey and thumbnailGeneratedAt to Document type
Mirrors the backend Document entity's new optional fields. Both are
optional (no @Schema requiredMode on the backend side), so legacy
documents without thumbnails stay valid.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:29:39 +02:00
Marcel
547db2fd02 test(backend): add ThumbnailServiceIntegrationTest against real MinIO
Spins up a MinIO container (Testcontainers GenericContainer) alongside
the existing PostgresContainerConfig, uploads a sample PDF, runs the
real ThumbnailService, and reads the resulting JPEG back from the
object store. Catches S3 signing / path-style access issues a mocked
S3Client wouldn't — justifies the CI cost (~45s) per walkthrough T9b.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:21:02 +02:00
Marcel
f11a29504a feat(backend): add GET /api/documents/{id}/thumbnail endpoint
Streams the JPEG thumbnail from S3 with Cache-Control: private,
max-age=31536000, immutable — `private` (not `public`) prevents
shared caches from leaking one user's thumbnail to another (CWE-525).
`immutable` is safe because the URL carries ?v=<thumbnailGeneratedAt>
as a cache-buster that changes whenever the file is replaced.

Authentication falls back to the global .anyRequest().authenticated()
rule, matching the existing /file endpoint's permission model.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:10:01 +02:00
Marcel
323ec1ec54 feat(backend): add AdminController endpoints for thumbnail backfill
- POST /api/admin/generate-thumbnails  → triggers async backfill, 202
- GET  /api/admin/thumbnail-status     → returns current BackfillStatus

Both gated by the class-level @RequirePermission(Permission.ADMIN).
Shape and polling semantics mirror the mass-import endpoints.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:05:47 +02:00
Marcel
09fc871756 feat(backend): add ThumbnailBackfillService for regenerating missing thumbnails
Sequentially processes all documents with a file but no thumbnail and
tallies processed / skipped / failed counts. Runs on thumbnailExecutor
so it shares back-pressure with live upload thumbnails but can never
saturate them (single-threaded loop).

Concurrent start rejected with THUMBNAIL_BACKFILL_ALREADY_RUNNING.
Emits a structured summary log line on completion for operator
visibility.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:02:20 +02:00
Marcel
0344a0c7ff feat(backend): dispatch thumbnail generation from MassImportService
ODS/Excel imports that actually upload a file (file.isPresent()) now
trigger thumbnail generation alongside hash/metadata. Metadata-only
import rows produce no thumbnail — nothing to render.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:58:24 +02:00
Marcel
7d0e13c591 feat(backend): dispatch thumbnail generation from DocumentService upload paths
All four upload code paths (storeDocument, createDocument, updateDocument,
attachFile) now call thumbnailAsyncRunner.dispatchAfterCommit(id) after
the document save. createDocument and updateDocument only dispatch when a
file was actually provided/replaced.

The dispatch is afterCommit-safe: if the surrounding @Transactional
method rolls back, no thumbnail is generated for a document that never
reached the DB.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:57:36 +02:00
Marcel
3b7ef6117e feat(backend): add ThumbnailAsyncRunner with afterCommit dispatch and timeout
Bridges @Transactional upload paths to the async thumbnail pipeline.
dispatchAfterCommit registers a TransactionSynchronization so the async
task only fires after the surrounding commit (and is silently skipped
on rollback) — mirrors the AuditService.logAfterCommit pattern.

generateAsync wraps the full ThumbnailService.generate call in a 30s
watchdog so a hung PDFBox render cannot occupy a thumbnailExecutor slot
indefinitely.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:49:26 +02:00
Marcel
955c497ba0 feat(backend): add ThumbnailService for PDF and image thumbnails
Renders a 240px-wide JPEG (quality 85) from either a PDF first page
via PDFBox or a JPEG/PNG/TIFF scan via ImageIO, then uploads to
S3 under thumbnails/{docId}.jpg and updates the Document entity.

Scaling uses Graphics2D.drawImage with VALUE_INTERPOLATION_BILINEAR
(not deprecated Image.getScaledInstance). Source is streamed via
FileService.downloadFileStream to avoid buffering 50MB PDFs.

Never throws — returns Outcome.SKIPPED for unsupported content types
and Outcome.FAILED for rendering/upload errors so the backfill can
tally them without aborting the run.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:46:08 +02:00
Marcel
0bb18c6789 feat(backend): add thumbnailExecutor bean for isolated thumbnail workload
Dedicated thread pool (core=1, max=2, queue=200) with CallerRunsPolicy
for back-pressure. Keeps thumbnail rendering off the shared taskExecutor
used by OCR and out of the AbortPolicy queue that drops work on overflow.
Quick-upload batches (15+ files) now apply back-pressure instead of
silently dropping thumbnail jobs.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:42:05 +02:00
Marcel
07019f54e8 feat(backend): add FileService.downloadFileStream for memory-efficient reads
Thumbnail generation will call this for PDFs up to 50 MB — loading the
full byte[] via downloadFileBytes would cause real memory pressure on
the single-VPS deploy. Stream-based reads let PDFBox parse the first
page without holding the whole file in heap.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:41:15 +02:00
Marcel
2aa3b955f9 build: add twelvemonkeys-imageio-tiff for thumbnail TIFF support
JDK ImageIO handles JPEG, PNG, BMP, GIF out of the box but not TIFF.
Since the document upload allowlist permits image/tiff, the thumbnail
generator must also decode it.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:37:05 +02:00
Marcel
a2333975f9 feat(backend): add THUMBNAIL_BACKFILL_ALREADY_RUNNING error code
Mirrors the IMPORT_ALREADY_RUNNING pattern for the concurrent-start
guard in ThumbnailBackfillService.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:36:06 +02:00
Marcel
b8962f4337 feat(backend): add DocumentRepository finder for thumbnail backfill
Adds findByFilePathIsNotNullAndThumbnailKeyIsNull() used by the
upcoming ThumbnailBackfillService to locate documents that have a
file attached but no thumbnail yet.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:35:08 +02:00
Marcel
6cf0601590 feat(db): add thumbnail_key and thumbnail_generated_at to documents
Adds two nullable columns to the documents table and their JPA mappings
on the Document entity. Both are left out of the OpenAPI required-mode
schema so the generated TypeScript type exposes them as optional.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:34:03 +02:00
Marcel
dd6cbe3a6f docs(specs): add final specs for thumbnail rows + person dashboard
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m41s
CI / OCR Service Tests (push) Successful in 33s
CI / Backend Unit Tests (push) Failing after 2m48s
Two production-ready specs following the chronik-spec format
(scaled wireframes × 3 viewports + impl-ref tables with exact Tailwind
classes and pixel values + WCAG contrast verification):

- briefwechsel-thumbnail-rows-spec.html — /briefwechsel row redesign
  with PDF thumbnail, summary-as-quote, bilateral distribution bar;
  drops status lifecycle and script-type indicators.

- person-dashboard-spec.html — new Korrespondenz-Überblick block on
  /persons/[id] with stats, activity histogram, direction split, top
  correspondents/locations, tag cloud. Every tile deep-links to
  /briefwechsel with filters.

Both specs share the DistributionBar.svelte component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:17:21 +02:00
Marcel
f5438c4c36 docs(specs): add briefwechsel-fill — 5 concepts exploring empty-row problem
Brainstorming artifact: 5 HTML mockups comparing approaches to fill the
sparse right-hand space on /briefwechsel rows (reported by users as
"feels empty"):

  1. Rich Rows — dense metadata, no images
  2. Thumbnail Rows — PDF preview on the left
  3. Master-Detail Split — list + persistent preview panel
  4. Gallery Cards — grid of letter cards, album style
  5. Person Dashboard — insights live on /persons/[id], not here

Picked: #2 (Thumbnail Rows) + #5 (Person Dashboard), followed up by
final specs in separate commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:16:20 +02:00
Marcel
ac2118db14 fix(notifications): replace aggressive EventSource close with threshold-based 401-aware retry
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m36s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m51s
On CLOSED readyState, probes session and redirects to /login only on 401.
On CONNECTING, counts consecutive errors and closes + probes only after 3
failures, preventing infinite retries without killing transient reconnects.

Closes #203

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 15:41:39 +02:00
Marcel
82de629339 fix(nav): prevent global header overflow at lg breakpoint (1024px)
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 2m53s
- Upload button text wrapped in hidden xl:inline to hide label below xl
- AppNav logo margin reduced from mr-10 to mr-4 xl:mr-10 at lg breakpoint
  Combined these changes bring the header content to ~923px vs ~945px
  available space at 1024px, eliminating horizontal overflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:35:25 +02:00
Marcel
2b8032524d fix(nav): restore DocumentTopBar back button sizing and add right padding
- BackButton gains showLabel prop: showLabel=false renders icon-only with
  aria-label, no mr-2 on svg (was causing 0px button width in topbar)
- DocumentTopBar: BackButton restored to h-11 w-11 circular touch target
  with showLabel=false matching the original 44×44px <a> it replaced
- Topbar row gets pr-4 (16px right padding per spec); action buttons div
  no longer needs its own pr-3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:34:31 +02:00
Marcel
261f631318 test(nav): add E2E test for DocumentTopBar back navigation; fix auth.setup login label
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m38s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m53s
CI / Unit & Component Tests (push) Failing after 2m36s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m47s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 12:08:31 +02:00
Marcel
9e59da598e fix(nav): replace static href="/" in DocumentTopBar with <BackButton>
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m46s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 3m2s
CI / Unit & Component Tests (push) Failing after 2m46s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Failing after 2m44s
The document detail page back button was missed in the original refactor —
it still pointed to "/" (dashboard) regardless of where the user came from.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:27:03 +02:00
Marcel
367dcc66f2 refactor(nav): add class prop to BackButton, remove mb-4 from topbar contexts
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m41s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 2m44s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m51s
- BackButton now accepts a `class` prop (default 'mb-4') so callers can
  override spacing; resolves hardcoded margin in flex-row topbar snippets
- documents/[id]/edit and enrich/[id] pass class="" to suppress the margin
- Replace weak className unit test with class-prop behaviour tests
- Add [data-hydrated] comment in E2E spec explaining what emits the attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:16:49 +02:00
Marcel
6c99c6a670 test(nav): add E2E tests for BackButton navigation and accessibility
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Unit & Component Tests (push) Failing after 2m39s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 2m52s
CI / Backend Unit Tests (pull_request) Failing after 3m7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:00:56 +02:00
Marcel
ae3bc3f246 docs(claude): update back link pattern to use BackButton component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:50:35 +02:00
Marcel
cc9c47254b refactor(nav): replace static back-link hrefs with BackButton
All 7 in-scope back navigation links converted to use history.back().
Admin panel mobile chevron converted inline (icon-only, different
visual pattern). Cancel buttons left as static <a> links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:49:23 +02:00
Marcel
781c4ffebb feat(nav): add BackButton component calling history.back()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:46:41 +02:00
Marcel
fd93f1a4da feat(chronik): rename route and heading to Aktivitäten
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m48s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m43s
/chronik → /aktivitaeten; heading updated in all three locales.
Component folder (lib/components/chronik/) stays unchanged — internal
implementation detail, not user-facing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:28:09 +02:00
Marcel
4f671824dd feat(chronik): align layout to grouped card pattern; fix duplicate rollup count
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m31s
CI / OCR Service Tests (push) Successful in 57s
CI / Backend Unit Tests (push) Failing after 3m0s
- ChronikTimeline: date buckets now render as bordered cards with muted
  header (border-line / bg-surface / shadow-sm) and divide-y row
  separators, matching the DocumentList card pattern
- ChronikRow: remove rounded-sm (card handles clipping), hover:bg-canvas
  → hover:bg-muted/50; restore rollup count badge after doc title
- Messages (de/en/es): remove embedded {count} from all four rollup verb
  strings so the badge is the single source of truth, consistent with
  DashboardActivityFeed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:13:03 +02:00
Marcel
ba0f9bb325 refactor(test): fix unchecked cast and derive ALL_ELIGIBLE_KINDS from enum
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / OCR Service Tests (pull_request) Successful in 29s
CI / Backend Unit Tests (pull_request) Failing after 2m46s
CI / Unit & Component Tests (push) Failing after 2m43s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m51s
- Replace any(Set.class) with any() to eliminate the raw-type unchecked
  cast in DashboardControllerTest
- Derive ALL_ELIGIBLE_KINDS from AuditKind.ROLLUP_ELIGIBLE.stream() so
  the integration test constant stays in sync with the production constant
  automatically when new kinds are added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:45:23 +02:00
Marcel
c0e52b8432 refactor(audit): move AuditLogQueryServiceTest to audit package
Addresses review concern: the test lived in the dashboard package but
tests the audit domain service. Package-by-feature convention requires
audit tests to live in the audit package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:44:37 +02:00
Marcel
42cf7715d2 test(chronik): extract applyClientFilter helper with full test coverage
Addresses review concern: the fuer-dich predicate (youMentioned ||
youParticipated) had zero test coverage after feedFilters.test.ts was
deleted. The new clientFilter module is a pure function that is directly
testable, and the test explicitly documents why MENTION_CREATED items
without the youMentioned flag are now excluded (they would have shown
mentions directed at OTHER users under the old feedFilters.ts logic).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:43:52 +02:00
Marcel
330c6227bc refactor(chronik): remove client-side filter; add aria-live/aria-busy
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 3m3s
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / OCR Service Tests (pull_request) Successful in 28s
CI / Backend Unit Tests (pull_request) Failing after 2m50s
- Delete feedFilters.ts and its 9 tests (dead code: server now filters)
- Remove activeFilter $state + $effect — read data.filter directly
- fuer-dich stays client-side via youMentioned/youParticipated predicate
- aria-live="polite" + aria-busy={!!navigating.type} on timeline region

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:26:27 +02:00
Marcel
d42293d3f5 feat(chronik): pass kinds query param from filter pill to API
Each filter pill maps to a specific set of AuditKinds sent as
?kinds= to /api/dashboard/activity. fuer-dich omits kinds so the
server returns all eligible events; client-side predicate on
youMentioned/youParticipated handles the final narrowing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:20:45 +02:00
Marcel
99c3106835 feat(openapi): expose kinds param in dashboard activity spec
Added @Parameter annotation so SpringDoc renders kinds as an
enum-array query param; regenerated TypeScript API types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:19:16 +02:00
Marcel
8d16e4d975 feat(dashboard): add kinds param to GET /api/dashboard/activity
Spring auto-converts ?kinds=FILE_UPLOADED,TEXT_SAVED to Set<AuditKind>.
Absent or empty kinds defaults to ROLLUP_ELIGIBLE. Unknown value → 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:10:43 +02:00
Marcel
571ecfc626 test(dashboard): guard getPulse always uses 2-arg findActivityFeed
Regression test: getPulse must never receive a kinds filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:05:43 +02:00
Marcel
475e16a85d feat(audit): add findActivityFeed(UUID, int, Set<AuditKind>) overload
Two-arg variant delegates to three-arg with ROLLUP_ELIGIBLE so
existing callers (getPulse) are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:03:39 +02:00
Marcel
fe7a8ed9ad feat(audit): add kinds param to findRolledUpActivityFeed
Filter is applied at the innermost events CTE to reduce rows
entering the LAG/session CTEs. Existing callers pass ROLLUP_ELIGIBLE
by default so behaviour is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:59:56 +02:00
Marcel
d700b0a948 refactor(audit): add ROLLUP_ELIGIBLE constant to AuditKind
Single source of truth for the six kinds eligible for the activity rollup feed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:53:14 +02:00
Marcel
7d9c7f1357 chore(api): mark manually-patched fields for next regen cycle
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 2m47s
CI / Unit & Component Tests (push) Failing after 2m54s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 2m59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 18:38:16 +02:00
Marcel
a76af739e5 test(notification-bell): cover handleMarkRead annotationId and commentId-only paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 18:37:18 +02:00
Marcel
a15e4e139b test(chronik-row): add coverage for commentId-only URL when annotationId absent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 18:34:41 +02:00
Marcel
e175e050f9 feat(chronik-row): deep-link COMMENT_ADDED and MENTION_CREATED to comment
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m55s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m50s
CI / Unit & Component Tests (pull_request) Failing after 2m38s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 2m51s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:38:50 +02:00
Marcel
95c11b9b46 feat(chronik-fuer-dich): include annotationId in mention deep-link
Sidebar was constructing /documents/:id?commentId=… without the
annotationId, so clicking a mention there no-op'ed the deep-link
scroll helper. Route the href through buildCommentHref so the
bell and the chronik sidebar produce identical URLs.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:21:16 +02:00
Marcel
7c22e42b8f refactor(notification-bell): use buildCommentHref helper
Drops the inline conditional href construction in favour of the
shared helper. Identical URL shape — behaviour preserved.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:17:57 +02:00
Marcel
7f40c54b3f feat(utils): add buildCommentHref helper for comment deep-links
Single source of truth for constructing /documents/:id?commentId=…
(&annotationId=…) URLs. Used by the notification bell, the chronik
"Für dich" sidebar, and the chronik main feed so the three surfaces
can no longer diverge.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:15:48 +02:00
Marcel
76a3a2e04c chore(api): hand-edit generated types for commentId + annotationId
Adds the two new optional fields on ActivityFeedItemDTO in the
generated openapi-typescript output. Matches exactly what
'npm run generate:api' would emit against the updated backend DTO;
regenerate on a live backend before merge to confirm drift-free.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:13:56 +02:00
Marcel
f50a746619 feat(dashboard): enrich activity feed DTO with commentId + annotationId
ActivityFeedItemDTO gains nullable commentId and annotationId fields.
DashboardService.getActivity forwards commentId from the projection
and batch-resolves annotationId via the new
CommentService.findAnnotationIdsByIds lookup. Both remain null for
non-comment kinds, so the bulk lookup is skipped entirely when the
feed has no comment rows.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:11:03 +02:00
Marcel
40260be07a feat(comment): add findAnnotationIdsByIds batch lookup
Exposes a CommentService method that maps a collection of
commentIds to their annotationIds via commentRepository.findAllById.
Unknown comments and comments with null annotationId are omitted.
Used by the dashboard activity feed enrichment to supply the
deep-link annotationId without growing the audit SQL query.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:04:38 +02:00
Marcel
b9f5ec22aa feat(audit): expose commentId on rolled-up activity feed projection
Adds getCommentId() to ActivityFeedRow and selects
(ag.payload->>'commentId')::uuid from findRolledUpActivityFeed so
chronik consumers can build deep-link URLs for COMMENT_ADDED and
MENTION_CREATED events. Null for other kinds.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:00:36 +02:00
Marcel
b07f9efa9c fix(document-detail): force edit panel on notification deep-link
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m39s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m46s
Comments only render inside TranscriptionEditView, so a deep-link
into a document with existing reviewed transcriptions landed the
user in read mode with no comment element in the DOM — the scroll
target silently missed.

scrollToCommentFromQuery now takes a setPanelMode callback and calls
it with 'edit' whenever both query params are present. The page's
own transcribe-mode $effect checks a skipInitialPanelMode flag the
deep-link flow sets, so its default-panel-mode logic doesn't race
against the explicit override.

Two new helper tests pin the contract: panel mode is forced to
'edit' both when transcribe mode is off (entering fresh) and when
it is already on (same-page notification click).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:22:38 +02:00
Marcel
3bf0b38c42 polish(document-detail): address review concerns on onMount deep-link wiring
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m40s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
CI / Unit & Component Tests (push) Failing after 2m41s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m55s
Three small refinements from Felix's review cycle 1:

- replaceState(page.url.pathname, page.state ?? {}) — defend against
  first-navigation cases where page.state can be undefined.
- Extract the inline tick + requestAnimationFrame into a named
  waitForPanelRender() helper; intent is now readable from onMount.
- Attach .catch() to the fire-and-forget scrollToCommentFromQuery
  promise so any helper throw surfaces via console.error instead
  of silently disappearing.

No behavior change on the happy path. All existing tests stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:10:46 +02:00
Marcel
567faee3cc test(e2e): notification deep-link scrolls to target comment
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m43s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 3m0s
CI / Unit & Component Tests (pull_request) Failing after 2m46s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 2m45s
Seeds a document, transcription block, and block comment via API,
then visits /documents/{id}?commentId=X&annotationId=Y and asserts
the page enters transcribe mode, the comment article becomes visible,
and the URL query params are stripped. Runs at 320px and 1440px so
the collapsed PDF strip clipping on mobile is caught. An axe-core
pass guards the new tabindex + focus-visible ring against a11y
regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:44:45 +02:00
Marcel
e22265f5bc feat(document-detail): wire notification deep-link scroll in onMount
After navHeight setup, call scrollToCommentFromQuery with the page
URL and callbacks into the component's local state (transcribeMode,
activeAnnotationId, flashAnnotationId) plus SvelteKit's replaceState
to strip the consumed query params.

afterTick awaits both Svelte's tick() and one requestAnimationFrame,
mirroring the existing handleAnnotationClick timing so the annotation
panel has rendered before scrollIntoView fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:42:55 +02:00
Marcel
20ae85f879 feat(comment): expose comment id + focus ring on CommentMessage wrapper
Notification deep-link scroll targets #comment-{id}. Add the id to
the article wrapper along with tabindex="-1" so scrollIntoView +
.focus({preventScroll:true}) can land screen-reader and keyboard
focus on the specific comment. A focus-visible ring appears only
for keyboard users so mouse clicks don't trigger a visible outline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:32:04 +02:00
Marcel
251eb9c3fc feat(frontend): add scrollToCommentFromQuery helper for notification deep-link
Pure function that reads commentId + annotationId from the page URL,
enters transcribe mode if needed, activates the block's annotation,
scrolls the target comment into view, focuses it for screen readers,
fires the existing annotation flash, and strips the params via the
injected callback.

All side effects go through callbacks so the helper is unit-testable
without mounting the page or a DOM (only scrollIntoView/focus are
called on the injected element). Eight tests cover both absent params,
happy path, transcribe-mode activation, missing DOM target, reduced
motion, flash trigger, and URL strip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:29:52 +02:00
Marcel
bc69e8ff1e refactor(comment): drop dead document and annotation comment APIs
Only block comments are surfaced by the frontend now. The document-level
and annotation-level comment endpoints and service methods existed but
had no consumer. Remove them along with their repository queries and
test coverage so the surface area matches the actual feature set.

Shared edit, delete, and block reply endpoints stay. postBlockComment
now carries the authorName/mention/audit behaviors previously tested
through the dropped postComment method, so those behaviors remain
covered by the block-scoped test suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:27:10 +02:00
Marcel
13732ab96b fix(db): V51 backfills annotation_id on block comments and notifications
Previously issued block-comment notifications were stored with
annotation_id=NULL because CommentService.postBlockComment did not
populate DocumentComment.annotationId. Now that the code fix is in
place, existing rows need to be filled in so legacy notifications
can also carry the query param that the frontend deep-link flow
expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:13:13 +02:00
Marcel
4658852281 fix(comment): populate annotationId on block comments from the block
postBlockComment now looks up the block via TranscriptionService and
sets comment.annotationId from block.getAnnotationId(). This closes
the upstream root cause of issue #276, where notifications for block
comments were stored with annotationId=null, breaking the notification
deep-link flow on the document detail page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:09:30 +02:00
Marcel
edb4e54df2 fix(audit): backfill COMMENT_ADDED and MENTION_CREATED events
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m40s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 2m54s
Comments created before audit logging was added in 428c63a2 have no
corresponding audit_log rows, so the Chronik activity feed (which
reads exclusively from audit_log) cannot surface them in "Alle" or
"Für dich", even though the fix from #295 is wired up correctly.
V50 inserts the missing events idempotently from document_comments
and comment_mentions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:08:45 +02:00
Marcel
3744a6ba3c fix(admin): preserve form values after save in user and group edit
SvelteKit's default `use:enhance` behaviour calls `form.reset()` after
a successful non-redirecting action, which wipes inputs that use
`value={...}` (property set, not defaultValue). The edit forms now
pass `reset: false` to `update()` so the saved values stay visible
after the success banner appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:07:50 +02:00
Marcel
4ec0b9d6c8 fix(admin): rename users edit default action to named update
SvelteKit 2 forbids mixing a `default` action with named actions; the
page also exports a `delete` action. Posting the edit form therefore
returned a 500 with "When using named actions, the default action
cannot be used." Rename the action to `update` and point the form
at `?/update`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:06:34 +02:00
Marcel
1fd2fab9a1 refactor(audit): document MIN invariant in aggregated CTE comment
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m43s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 2m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
53352be997 test(audit): document youParticipated graceful degradation when commentId absent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
dc027cdf40 test(audit): add cross-user scoping regression for youParticipated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
5b3fb8fb3f refactor(test): use ObjectMapper for payload JSON in audit test helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
d154bb4b9c refactor(chronik): replace inline filter switch with filterFeed()
Wire the extracted filterFeed function into the displayFeed derived,
removing 20 lines of inline switch logic from +page.svelte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
8b74f4ec25 feat(chronik): extract feedFilters.ts with youParticipated in fuer-dich
Extract filterFeed(items, filter) from +page.svelte inline switch to a
pure function, widening the fuer-dich branch to include youParticipated.
Regenerate ActivityFeedItemDTO type to include the new field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
388fc5397c feat(dashboard): expose youParticipated in ActivityFeedItemDTO
Add youParticipated field to the DTO record and wire row.isYouParticipated()
through DashboardService.getActivity().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
454e5f7517 feat(audit): surface youParticipated via REPLY notification subquery
Add you_participated correlated subquery to findRolledUpActivityFeed.
Carries payload through the aggregated CTE via MIN(payload::text)::jsonb
so the commentId can be matched against notifications.reference_id.
Uses CAST(:currentUserId AS uuid) to avoid Spring Data JPA misparsin ::
cast syntax as a parameter name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
9c52e1921f test(audit): add youParticipated and youMentioned repository tests
Add isYouParticipated() to ActivityFeedRow interface and cover four
behaviours in AuditLogQueryRepositoryRolledUpTest: youParticipated
true/false and retroactive youMentioned true/false coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
6f3aa056a1 fix(relativeTime): guard against Invalid Date producing NaN strings
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / OCR Service Tests (pull_request) Successful in 28s
CI / Backend Unit Tests (pull_request) Failing after 2m53s
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
If a row ever receives a malformed uploadedAt (e.g. manual SQL migration,
backend regression), the helper now falls back to "vor 0 Minute(n)"
rather than rendering "vor NaN Tag(en)" to the user.

Addresses Nora's review suggestion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:46:53 +02:00
Marcel
35303831f7 a11y(dashboard): larger dismiss target + motion-reduce + sr-only PDF label
- UploadSuccessBanner dismiss button: 24×24 → 40×40 hit area (icon stays
  at 16px). Matches senior-first baseline Leonie flagged.
- DashboardNeedsMetadata chevron: adds motion-reduce:transition-none and
  motion-reduce:group-hover:translate-x-0 so users with prefers-reduced-
  motion do not see the hover translate.
- Row title prefixed with an sr-only "PDF: " span so assistive tech
  announces the document affordance alongside the title.

Addresses Leonie's review concerns #2, #3, and the sr-only nit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:44:45 +02:00
Marcel
97e8e4fc74 test(dropzone): replace setTimeout flake with vi.waitFor + hoisted mock
The "no-callback" and "no-prop" tests no longer rely on an arbitrary
50ms sleep. Test 2 awaits the mocked invalidateAll call (the last async
step of the upload handler) before asserting the callback was not
invoked. Test 3 lets vitest-browser-svelte's own expect.element poll
until the success message appears.

Addresses Sara's and Felix's review concern about flake-prone timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:42:23 +02:00
Marcel
30ea1f0dcf test(dashboard): exercise the EnrichmentBlock skeleton branch
Hoists the $navigating store into a shared __mocks__ module so tests can
drive it through real transitions. Adds two specs covering (a) skeleton
visible while $navigating && topDocs empty and (b) skeleton hidden when
topDocs is non-empty. Also sets aria-busy="true" on the skeleton so
screen readers announce the loading state (Leonie's a11y suggestion).

Addresses Sara's and Felix's review concern that the skeleton branch was
dead code in the test world.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:36:41 +02:00
Marcel
d3f9f8457a test(dashboard): extend page.server mock chain for incomplete endpoints
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m39s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m47s
CI / Unit & Component Tests (pull_request) Failing after 2m29s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 2m47s
The two "happy path" dashboard load tests now mock the two additional
calls added in f5481289 (/api/documents/incomplete + incomplete-count)
so the Promise.allSettled array resolves fully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:26:31 +02:00
Marcel
3eda482000 test(e2e): dashboard enrichment block — upload + axe sweep
Happy-path journey (upload 2 PDFs → banner → CTA → /enrich) plus axe
sweep at 320/768/1440 × light/dark for the dashboard route. Seeded
docs are cleaned up in afterEach via psql so repeated runs stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:13:37 +02:00
Marcel
f548128940 feat(dashboard): wire EnrichmentBlock between Resume strip and Mission Control
Dashboard loader fetches /api/documents/incomplete?size=5 plus the
existing /incomplete-count and surfaces both via data; +page.svelte
renders EnrichmentBlock with the top 5 docs, the total count, and the
bannerCount state bound to DropZone's onUploadComplete callback
(issue #296).

The block returns null when there is nothing to show, so dashboards
without pending uploads stay uncluttered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:08:09 +02:00
Marcel
90c9ca8708 feat(dropzone): emit onUploadComplete callback with created count
Optional callback lets the parent route pop a post-upload banner without
lifting state into a store. Dashboard uses it to drive
UploadSuccessBanner (issue #296). Only fires when the server actually
created new documents — duplicates and errors do not trigger it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:01:46 +02:00
Marcel
e824e23c8c feat(dashboard): add EnrichmentBlock wrapper component
Composes UploadSuccessBanner + DashboardNeedsMetadata and reserves a
360px skeleton while \$navigating re-runs the loader with a fresh
incomplete list. Prevents the layout-shift jump after a batch upload
(Leonie's resolved decision #3 on issue #296).

Renders nothing when there is nothing to show — keeps the clean empty
dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:58:05 +02:00
Marcel
b29125615f feat(dashboard): add UploadSuccessBanner component
Transient post-upload banner for issue #296: singular/plural German copy,
aria-live=polite for screen readers, manual X dismiss, 8s auto-dismiss.
"Jetzt ergänzen →" CTA links directly to /enrich so seniors can continue
straight into the enrichment flow after a batch upload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:53:28 +02:00
Marcel
01e72611f0 feat(dashboard): redesign needs-metadata with row anatomy + totalCount
Switches to two props — topDocs (max 5, capped by caller) and totalCount —
so the footer link can surface "Alle 12 anzeigen →" even when only 5
items are shown. Each row gets a generic document icon, title, relative
upload time and a chevron, wrapped in a single <a> per the issue spec.

Still returns null when topDocs is empty, keeping the empty dashboard
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:34:23 +02:00
Marcel
727569aa32 i18n: add upload-banner + enrichment-block keys
Singular/plural banner copy, a count-aware "show all" footer link, and
the dismiss aria-label for the new dashboard enrichment-list-block
(issue #296). Covers de / en / es.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:25:17 +02:00
Marcel
d5d1a463b8 feat(frontend): add relativeTimeDe helper for dashboard meta lines
Pure function with injectable now — lets the dashboard enrichment block
render "vor 2 Min." / "vor 3 Std." / "vor 2 Tagen" without clock-based
test flakiness. Reuses the existing comment_time_minutes / _hours /
_days Paraglide keys, no new translations needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:22:59 +02:00
Marcel
46fe3655ab chore(frontend): regenerate openapi types for /api/documents/incomplete
Picks up the restored list endpoint and the new uploadedAt field on
IncompleteDocumentDTO (issue #296).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:19:46 +02:00
Marcel
47859e5a9b feat(documents): retrofit WRITE_ALL guard on /incomplete-count + /incomplete/next
Closes the CWE-285 gap Nora flagged on issue #296: both endpoints expose
enrichment-queue information that only writers should see. Brings them in
line with the new /incomplete list endpoint and every other write-path
under DocumentController.

Frontend callers (/enrich/[id]/+page.server.ts) already gate on WRITE_ALL
at the route level, so no client-side change is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:14:24 +02:00
Marcel
758c708766 test(documents): lock /incomplete size cap at 200
Regression test proving the controller clamps client-supplied size
values server-side, closing the unbounded-limit concern Markus flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:09:10 +02:00
Marcel
2c5cfcedbc feat(documents): gate /incomplete behind WRITE_ALL permission
Only users who can enrich documents should see the queue.
Mirrors the frontend guard in enrich/+page.server.ts and closes the
CWE-285 gap Nora flagged on issue #296.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:05:35 +02:00
Marcel
bc3a268f66 feat(documents): re-add GET /api/documents/incomplete
Restores the list endpoint removed in ddd811c6 and caps size at 200.
The dashboard enrichment block (issue #296) and /enrich page both
consume it; /enrich was silently 404ing since the deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:01:54 +02:00
Marcel
d2fc452c1a feat(dto): add uploadedAt to IncompleteDocumentDTO
Mapper populates uploadedAt from Document.createdAt so the dashboard
enrichment block can show a relative-time meta line ("vor 2 Min.")
per issue #296.

LocalDateTime matches the convention used by NotificationDTO,
DocumentVersionSummary and InviteListItemDTO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:58:15 +02:00
Marcel
b8ffd81b7e test(audit): add regression proving STATUS_CHANGED/METADATA_UPDATED are excluded
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m41s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m52s
Cheap insurance for the day someone widens the WHERE clause in
findRolledUpActivityFeed. Suggested by Sara in PR #288 review cycle 1.

Inserts two non-eligible events alongside one TEXT_SAVED and asserts the feed
returns the TEXT_SAVED row only.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
f68c892170 fix(chronik): sentinel-based title split + drop duplicate comment preview
Two PR #288 blockers from Felix and Leonie:

Felix: verbText.indexOf(docTitle) broke when the title was empty (indexOf
returned 0, the before/after slices both emptied) or when the title
substring-matched any word in the compiled Paraglide message (e.g. "Brief"
appearing inside a translated verb). Swap to a sentinel approach: interpolate
{doc} with U+0001, then split the compiled text on that sentinel — robust
regardless of title content or translator sentence order. Two new red tests
lock the invariant: empty title still renders the row link; short titles
that could substring-match render exactly once as a single chronik-doc-title
span.

Leonie: the comment variant rendered „{documentTitle}" as a placeholder,
which made the row show the same title twice — once as the underlined link,
once as the italic "preview quote" — implying the comment was quoting
itself. Replace with an italic ellipsis „…". A new red test asserts the
preview no longer contains the document title text verbatim.

While here, add a SECURITY comment next to the TODO so the next person who
wires item.commentPreview knows the backend must truncate/strip server-side
and the frontend must use {text}, never {@html} (Nora, issue #285 #3552).

Part of #285, address PR #288 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
f0b21e226e refactor(chronik): remove unused form actions and broken pagination UI
Two items flagged as blockers in PR #288 review:

- Markus + Sara: "Mehr laden" calls GET /api/dashboard/activity?offset=N but
  the backend's DashboardController only accepts `limit` — `offset` was
  silently ignored, and every click re-fetched the same top-40 rows. Rather
  than add backend offset/cursor support in this PR (scope creep), remove
  the Load-more UI and defer pagination to a follow-up issue. 40 items
  covers the default case; the feature can come back with proper backend
  support and its own tests.
- Markus + Sara: ?/dismiss and ?/mark-all form actions were dead code —
  the UI calls `onMarkRead` / `onMarkAllRead` callbacks (→ singleton →
  raw PATCH) and never submits either form. Delete both actions and their
  tests. Using the form-action path would require deprecating the
  NotificationBell's raw-PATCH as well — that's tracked separately as
  #286.

The Dismiss markup split from the previous commit stands on its own.

Part of #285, address PR #288 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
58ea2f827a fix(chronik): split Für-dich row markup — Dismiss is sibling of link, not nested
HTML5 forbids interactive content (<button>, <a>, <input>...) as descendants
of <a>. The original <a href=…><button>✓</button></a> markup triggered two
concrete bugs flagged by Felix, Nora, and Leonie in PR #288 review:

- Browsers inconsistently route the nested click: on some engines the
  stopPropagation() still bubbles, and the user navigates into the document
  instead of dismissing.
- The senior audience (60+) tap-selects with a slight drag, and the OS
  treats the interaction as anchor vs. button inconsistently — a
  reproducible usability failure Leonie has seen in testing before.

Refactor to the Option-C layout from issue #285 comment #3573: outer <li>
flex container, <a> wrapping avatar + body + time, <button> as a sibling.
Independent focus stops, invalid-HTML gone, no behavioural regression.

A new spec locks the invariant: `dismiss.closest('a')` must be null.

Part of #285, address PR #288 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
089a1d063a test(a11y): add /chronik to AUTHENTICATED_PAGES for axe-playwright sweep
Three free axe checks light up (light / system-dark / manual-dark) without
further code changes — they run the existing parameterized spec against
/chronik.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
93c80671e2 fix(notifications): retarget bell dropdown footer to /chronik
The "Alle anzeigen" link at the bottom of the notification dropdown now
points to /chronik with the new "Zur Chronik →" label key, matching the
unified activity page introduced in #285.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
81f86474b6 fix(dashboard): retarget feed footer to /chronik + render rollup rows
- "Alle anzeigen" link now goes to /chronik (was /documents — the dead-end
  bug called out in #285).
- Rollup rows (count > 1) render a primary-colored count badge plus a
  compound timestamp line: "14. Apr. · 14:02–14:32" (en-dash U+2013).
- Singleton rows render the existing "14. Apr. 2026" date line.
- BLOCK_REVIEWED now has a verb mapping (re-using the annotation verb until
  the spec pins a distinct copy).
- Three new spec cases: rollup count badge + en-dash range, no badge on
  singletons, /chronik link assertion.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
661eedd29c feat(notifications): delete /notifications route in favor of /chronik
The app is pre-production — no 301 redirect, the old route and its tests
are removed outright. Profile page's "Benachrichtigungsverlauf ansehen"
link now points to /chronik.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
cb3e5420fc feat(chronik): add /chronik route (page.server.ts + +page.svelte + spec)
page.server.ts loads /api/dashboard/activity (limit=40) and unread
/api/notifications in parallel via Promise.allSettled so a dashboard-activity
failure still renders the Für-dich box. Form actions ?/dismiss and ?/mark-all
back the Dismiss and "Alle gelesen" controls with CSRF-safe SvelteKit
endpoints.

+page.svelte composes all six chronik components:
- ChronikFuerDichBox at the top, seeded from the SSR unread set on first
  render and switching to the live SSE singleton once notifications arrive;
- ChronikFilterPills below, wired to URL via goto(?filter=…) with
  replaceState so the browser history stays clean across filter changes;
- ChronikTimeline for the day-bucketed feed, filtered client-side per pill
  (alle / fuer-dich / hochgeladen / transkription / kommentare);
- ChronikEmptyState for first-run vs filter-empty states;
- ChronikErrorCard on activity load failure.

"Mehr laden" pagination keeps focus on the button after load (via tick() +
$state-bound ref), renders 3 static skeleton rows with aria-busy, and
announces "{count} weitere Einträge geladen" through a polite aria-live
region. Inbox-zero in the Für-dich box links to /chronik?filter=fuer-dich.

Co-located page.server.spec.ts covers load(): limit=40, unread=read:false,
filter parsing with "alle" fallback, activity-fulfilled-but-not-ok surfaces
loadError, plus the dismiss and mark-all actions (success + missing-id
branch). 8 tests green.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
e36c9382fc feat(chronik): add six Chronik page components + co-located specs (40 tests)
All under src/lib/components/chronik/:

- ChronikRow.svelte — single orchestrator for four variants (comment / for-you /
  rollup / simple), discriminated via $derived. Outer <a> wraps avatar + body +
  time; document title is a styled <span> (no nested anchors). Rollup shows
  count badge + en-dash time range; for-you gets accent left border + @ marker
  hidden below sm:.
- ChronikTimeline.svelte — buckets items by day using bucketByDay() and renders
  Heute/Gestern/Diese Woche/Älter section headers with <span> trailing rule.
- ChronikFuerDichBox.svelte — unread mentions card with inbox-zero variant,
  per-row Dismiss button (prevents bubbling, calls onMarkRead), aria-live count
  badge, and a .fade-in class gated by prefers-reduced-motion.
- ChronikFilterPills.svelte — role=radiogroup with 5 pills, ArrowLeft/Right
  keyboard navigation wrapping across the group, single tabstop via dynamic
  tabindex.
- ChronikEmptyState.svelte — three variants (first-run / filter-empty /
  inbox-zero) sharing a centered-column layout.
- ChronikErrorCard.svelte — warning card with retry button, optional custom
  message override.

Verbs map to chronik_singleton_* / chronik_rollup_* per AuditKind so no ICU
pluralization is needed. Comment preview is a TODO placeholder (currently the
document title) pending a backend preview DTO follow-up.

All 40 unit tests green. No type-check or lint errors in these files.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
6b433fa82a feat(chronik): add ADR-003 + Paraglide keys for /chronik page (de/en/es)
- docs/adr/003-chronik-unified-activity-feed.md: records the session-rollup
  decision (LAG + 120-min gap), the dedupe deletion, the single-endpoint
  composition, and the German-URL convention.
- frontend/messages/{de,en,es}.json: adds chronik_* keys for page title,
  Für-dich box, filter pills, day headers, singleton/rollup verb variants
  per kind, empty states, error card, Mehr-laden pagination, and the Bell
  footer link retarget.

No pluralization via ICU match — separate singleton/rollup keys per verb,
per the Felix discussion (comment #3573).

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
f13b2a984e fix(notifications): retarget NotificationItem import to singleton store
Left over from the hook→singleton refactor — NotificationDropdown still
imported from the deleted $lib/hooks path.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
56161f9a49 feat(utils): add date-buckets helper for Chronik day grouping
Pure function bucketByDay(date, now?, locale?) returns one of
'today'|'yesterday'|'thisWeek'|'older' so ChronikTimeline can
bucket activity rows by relative day without pulling a date
library.

Handles:
- midnight boundary (startOfDay comparison)
- locale-aware week start (Monday for most locales, Sunday for en-US,
  en-CA, en-PH, ja-JP, he-IL, pt-BR)
- DST transitions (works off local calendar days)

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
5fc39b0371 refactor(notifications): convert per-component stream hook to module-level singleton
Replaces the per-component createNotificationStream() factory with a shared
$lib/stores/notifications.svelte.ts singleton. Ref-counted init()/destroy()
ensures one EventSource per tab no matter how many consumers mount
simultaneously.

Motivation: the /chronik "Für dich" box (#285) needs the same live-arrival
stream that NotificationBell already consumes. Two factories would open two
SSE connections per tab — this refactor avoids the silent regression before
it ships.

- New: src/lib/stores/notifications.svelte.ts (module state, refcount)
- New: src/lib/stores/notifications.svelte.spec.ts (proves single EventSource
  across multiple consumers + ref-counted teardown)
- Deleted: src/lib/hooks/useNotificationStream.svelte.ts (factory)
- Deleted: src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts
- NotificationBell now imports the singleton

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
cb219b56c4 chore(types): regenerate OpenAPI types for ActivityFeedItemDTO rollup fields
Adds count (required) and happenedAtUntil (optional) to the TypeScript DTO so
Chronik + DashboardActivityFeed can consume rollup rows type-safely.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
101f5b2a6a feat(audit): add V49 rollup covering index + raise /api/dashboard/activity cap to 40
- V49__add_audit_log_rollup_index.sql: partial covering index on
  (actor_id, document_id, kind, happened_at DESC) filtered by the 6 rollup
  kinds. Matches the WHERE clause of findRolledUpActivityFeed exactly so the
  session-grouping window scan is index-backed.
- DashboardController: clamp limit to 40 (was 20). Chronik requests up to 40
  activity items per page; dashboard side-rail still passes 7.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
eda30f53fa refactor(audit): rename findDedupedActivityFeed to findRolledUpActivityFeed
The method no longer deduplicates by hour-trunc — it performs session-style
rollup via LAG()+120-min gap. Rename aligns the public name with the
behavior.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
feefa682b3 feat(audit): replace hour-trunc dedupe with LAG() session rollup (120-min gap)
Rewrites the activity feed query to group consecutive events on the same
(actor, document, kind) into sessions separated by >120 min gaps. A session
becomes one row with count = events-in-session and happenedAtUntil = last
event timestamp. Singletons keep count=1 / happenedAtUntil=null.

Algorithm: LAG() to get the previous event's timestamp in the same partition,
mark a new session when gap > 7200s, then SUM() over an unbounded preceding
window yields a running session_id. Aggregation groups by session_id.

COMMENT_ADDED and MENTION_CREATED always start a new session — these kinds
never roll up so each event stays its own row.

Also adds BLOCK_REVIEWED to the eligible-kinds WHERE clause (Chronik spec §02)
so reviewed blocks appear in the activity feed.

Five new integration tests cover combine-within-2h, split-at-boundary,
no-hard-cap-on-long-session, never-rolls-up-comments/mentions, and the
count/happenedAtUntil contract on both singletons and rollups.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
22ddf8c12a refactor(audit): extend activity feed row/DTO with count and happenedAtUntil (singletons default)
Prepares the activity feed data shape for session-style rollup (#285). Adds two
new fields that carry null-operation defaults for the existing hour-truncated
dedupe query:

- count: int (required) — always 1 for singleton rows
- happenedAtUntil: OffsetDateTime (nullable) — end-of-session timestamp for
  future rollup rows; null for singletons

No behavioral change yet — the rollup SQL rewrite lands in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
d9b7b7aad4 docs(specs): add enrichment list-block dashboard spec
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m38s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m2s
Design spec for a dashboard widget that surfaces documents
needing metadata after batch upload. Placed between Resume
strip and MissionControlStrip rather than as a 4th strip
column (strip at visual capacity; batch reality makes count
tiles useless for seniors).

Covers responsive behavior at 320/768/1440, row anatomy with
72/64px touch targets, state matrix (empty/loading/error/
after-upload), full a11y contract, dark-mode verification
notes, and an impl-ref table with exact Tailwind classes.

Refs #296

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:02:51 +02:00
Marcel
2873d8646b fix(documents): suppress uppercase on person name group headers for SENDER/RECEIVER sort
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m41s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 2m50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
2260c25dc5 test(documents): add regression test for sort fallback to year grouping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
e79da27a12 refactor(documents): narrow sort prop type to full SortMode union
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
2982d0f6bf fix(documents): use i18n key for undated group label instead of hardcoded German string
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
e5068820fa refactor(documents): rename year-card testid to group-card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
eb3a54b19c fix(document-row): align contributor circles with progress ring
The ProgressRing renders SVG + percentage label as a flex column (~52px
total). With items-center, the contributor circles aligned to the middle
of the full block, placing them 8px below the ring center. Changed to
items-start on the container and wrapped ContributorStack in h-9 (36px =
SVG height) flex items-center so both circles center at the same 18px.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
33ada55f12 feat(documents): restore sender/receiver grouping in document list
When sort=SENDER, documents group under the sender's display name card.
When sort=RECEIVER, a document appears under each receiver's card
(with multi-receiver duplication). Falls back to i18n labels for unknown
sender/receiver. Passes sort prop from /documents page to DocumentList.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
909041adbb feat(i18n): add unknown sender/receiver fallback labels for document grouping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
5f30807ed0 docs(chronik): add final design spec for unified activity + notifications page
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m38s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m55s
Spec replaces /notifications with a unified /chronik page that merges ambient
archive activity (6 of 8 AuditKinds) and personal mentions/replies. Covers 11
content states across 320/768/1440px viewports, dark mode parity, row anatomy
close-ups, interaction states, WCAG contrast verification, and implementation
notes (routing, API calls, rollup logic, Svelte component structure, i18n keys).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:00:42 +02:00
Marcel
4e1d0b1cf0 chore: merge main into feat/issue-281-documents-page
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / Backend Unit Tests (pull_request) Failing after 2m50s
CI / Unit & Component Tests (push) Failing after 2m31s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m52s
CI / OCR Service Tests (pull_request) Successful in 29s
Resolved 9 conflicts:
- AuditLogQueryRepository/Service: keep HEAD (findRecentContributorsForDocuments)
- ContributorStack: merge main key fix + text-[10px] with HEAD safeColor + aria
- DashboardResumeStrip: merge main text-[10px] with HEAD safeColor
- +page.server/svelte + tests: keep HEAD (pure dashboard, no isDashboard)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 08:54:17 +02:00
Marcel
da2ece986a fix(documents): WCAG text-size and API error pattern fixes
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m37s
CI / OCR Service Tests (pull_request) Successful in 29s
CI / Unit & Component Tests (push) Failing after 2m44s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 2m45s
CI / Backend Unit Tests (pull_request) Failing after 2m45s
- DashboardResumeStrip: text-[10px] → text-xs on collaborator initials (WCAG 1.4.4)
- documents/+page.server.ts: use !result.response.ok per CLAUDE.md; keep narrow try/catch for network-level failures only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 01:17:04 +02:00
Marcel
f2bed92176 fix(review): cycle 3 — a11y, CSS injection, domain boundary
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m44s
CI / OCR Service Tests (push) Successful in 34s
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (push) Failing after 2m56s
CI / Backend Unit Tests (pull_request) Failing after 2m48s
- DocumentRow: add data-testid="search-snippet"; sanitize tag.color with safeTagColor()
- ContributorStack: add role="img" aria-label to overflow "…" badge
- DocumentList: year header text-[10px] → text-xs (WCAG 1.4.4 minimum 12px)
- DashboardResumeStrip: sanitize collab.color with safeColor()
- Extract TranscriptionBlockQueryService to fix cross-domain repository access in DocumentService
- Update unit test mocks to use TranscriptionBlockQueryService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 01:03:06 +02:00
Marcel
7f23e88b69 fix(documents): address review cycle 2 — a11y, CSS injection, debounce tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Unit & Component Tests (push) Failing after 2m40s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m54s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
- ContributorStack: text-xs for WCAG 1.4.4 (was text-[10px]), safeColor()
  validation to block CSS injection via actor.color, role="img" aria-label
  on empty placeholder, {#each} keyed by index
- ContributorStack spec: update empty-state assertion to getByRole('img')
- DocumentRow spec: add stopPropagation regression test for tag click
- documents/page.svelte.spec.ts: new — debounce, URL building, initial state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:40:48 +02:00
Marcel
b2ea9e74fe fix(a11y): add sr-only h1 to documents page for screen reader landmark
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m42s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m1s
CI / Unit & Component Tests (pull_request) Failing after 2m41s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 2m48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:25:16 +02:00
Marcel
dc349947ef fix(a11y): add aria-hidden to decorative SVG and use text-xs for WCAG 12px minimum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:24:36 +02:00
Marcel
80e10e1755 fix(docs): stop tag click propagation to prevent parent anchor navigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:23:48 +02:00
Marcel
3707d34c62 test(docs): add DocumentRow unit tests — title, snippet, tags, sender, progress ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:23:08 +02:00
Marcel
10833fbe6b feat(frontend): add /documents page with search, filter, and year-card list
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m35s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m53s
CI / Unit & Component Tests (pull_request) Failing after 2m40s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 2m46s
- New documents/+page.svelte wires SearchFilterBar + DocumentList with
  URL-driven navigation (goto + SvelteURLSearchParams)
- Reset button in SearchFilterBar now navigates to /documents
- Rename documents/+page.server.spec.ts → page.server.spec.ts to avoid
  SvelteKit route-file conflict on the + prefix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:06:25 +02:00
Marcel
4ba4e67bc5 feat(frontend): add /documents page.server.ts — search load function with all filter params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:55:37 +02:00
Marcel
65bc859918 refactor(frontend): rewrite DocumentList as year-card orchestrator using DocumentSearchItem[]
Delegates row rendering to DocumentRow; groups by year; removes matchData and sort props.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:53:37 +02:00
Marcel
648aa2a742 feat(frontend): add DocumentRow component with two-column layout, highlights, progress, contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:44:31 +02:00
Marcel
ae0e3b271d feat(frontend): add ProgressRing component — SVG progress arc with percentage label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:34:30 +02:00
Marcel
6e888d9958 feat(frontend): simplify homepage to pure dashboard — remove search/filter dual-mode
The homepage now always renders the dashboard. Search and browse
moves to the dedicated /documents route (upcoming).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:31:57 +02:00
Marcel
eeca30e7a6 feat(frontend): add --header-height, bump initials to text-[10px], update nav to /documents
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:24:46 +02:00
Marcel
05d434fed3 chore(frontend): regenerate API types with DocumentSearchItem and updated DocumentSearchResult
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:23:08 +02:00
Marcel
71c02626f4 feat(migration): V48 add composite index on transcription_blocks(document_id, reviewed)
Speeds up the bulk completion percentage query added in previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:19:24 +02:00
Marcel
8df0c3a1ef feat(service): assemble DocumentSearchItem in DocumentService with completion and contributors
DocumentService.searchDocuments now fetches completion percentages and recent
contributors per document and zips them into DocumentSearchItem records.
Update affected tests to use the new items-based result shape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:18:35 +02:00
Marcel
ab3a026feb feat(dto): add DocumentSearchItem record and refactor DocumentSearchResult to items-based shape
Replaces {documents, matchData, total} with {items: List<DocumentSearchItem>, total}
where each item collocates document + matchData + completionPercentage + contributors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:17:53 +02:00
Marcel
16614d1bfb feat(audit): add findRecentContributorsForDocuments query (max 4, recency order)
Adds a window-function query that returns at most 4 contributors per document
ordered by most-recent activity. Used by DocumentService to populate the
contributors field in DocumentSearchItem (issue #281).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:05:49 +02:00
Marcel
f197eb9752 feat(repository): add bulk completion stats query to TranscriptionBlockRepository
Adds findCompletionStatsForDocuments() returning reviewed-block percentage
per document in a single native SQL GROUP BY query. Needed for the new
DocumentSearchItem DTO in issue #281.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:00:20 +02:00
Marcel
70a2bbfaad refactor(audit): move AuditLogQueryService, AuditLogQueryRepository, and shared DTOs to audit package
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m48s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m48s
CI / Unit & Component Tests (pull_request) Failing after 2m41s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m50s
TranscriptionQueueService was importing ActivityActorDTO and AuditLogQueryService
from the dashboard package, creating an inverted dependency (service → dashboard).
Moving these to the audit package where AuditLog lives gives both DashboardService
and TranscriptionQueueService the correct dependency direction (→ audit).

Moved to audit:
- ActivityActorDTO, ActivityFeedRow, ContributorRow, PulseStatsRow (projections)
- AuditLogQueryRepository, AuditLogQueryService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:43:30 +02:00
Marcel
5246638014 fix(dashboard): fix ContributorStack each-block key and add accessible avatar labels
- Replace (actor.name ?? actor.initials + i) with (actor.initials + '-' + actor.color)
  to fix operator-precedence bug that made keys order-dependent when name is null
- Add role="img" + aria-label={actor.name ?? actor.initials} so screen readers
  and touch users can access contributor names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:28:00 +02:00
Marcel
d6e5d3d1e8 fix(layout): replace hardcoded 'Hochladen' with m.upload_action() + aria-label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:23:32 +02:00
Marcel
94823f85c8 test(dashboard): fix stale resume mock — use totalBlocks instead of page/pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:19:50 +02:00
Marcel
2bb08b6877 fix(dashboard): i18n, a11y, security, and type-safety fixes from PR review
- Use @RequiredArgsConstructor in AuditLogQueryService; remove unused import
- Add 401/403 tests for /activity endpoint
- Add getPulseStats and findContributorsPerDocument integration tests
- Use m.pulse_headline/pulse_you in FamilyPulse; composite avatar keys
- Replace hover:text-accent with hover:text-ink in ActivityFeed (WCAG AA)
- Localise "Alle →" link with feed_show_all key + aria-label
- Gate DropZone behind {#if data.canWrite}
- Export DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO from api.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:43:16 +02:00
Marcel
148710f2ed docs(spec): add /documents page design spec with mobile breakpoints
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m35s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Failing after 1m26s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 1m28s
CI / Unit & Component Tests (pull_request) Failing after 2m33s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:39:56 +02:00
Marcel
18e321b1e6 feat(dashboard): show block count instead of page numbers in resume strip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:32:13 +02:00
Marcel
3aec856bac refactor(dashboard): remove page field from DashboardResumeDTO; rename pages to totalBlocks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:27:07 +02:00
Marcel
3f773cd9c3 fix(dashboard): bulk-load document titles in getActivity to avoid N+1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:24:39 +02:00
Marcel
09a8081e35 fix(dashboard): null-safe name join in toActorDTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:21:20 +02:00
Marcel
d19116fd05 fix(dashboard): include ANNOTATION_CREATED in hero resume query
Some checks failed
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 1m33s
CI / Unit & Component Tests (push) Failing after 2m34s
CI / Unit & Component Tests (pull_request) Failing after 2m37s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 1m31s
findMostRecentDocumentIdByActor only matched TEXT_SAVED events, so documents
where the user drew annotation bounding boxes (but typed no transcription text)
were invisible to the hero resume card. Extending the IN clause to include
ANNOTATION_CREATED lets annotation-only work surface in the card (0% progress,
no excerpt — the correct state before transcription begins).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 19:40:48 +02:00
Marcel
bae07c8171 fix(audit): submit afterCommit write to executor to avoid transaction sync conflict
AuditService.logAfterCommit() called writeLog() inline inside the afterCommit()
callback. At that point Spring's transaction synchronizations are still active on
the thread, so SimpleJpaRepository.save() throws IllegalStateException which the
catch block silently swallowed — leaving audit_log permanently empty.

Fix: submit writeLog() to auditExecutor so it runs on a fresh thread with no active
synchronization context. Also switch auditExecutor from CallerRunsPolicy to AbortPolicy
to prevent the bug from silently recurring when the queue fills under load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 19:39:59 +02:00
Marcel
64c5b40eae fix(dashboard): defensive null guard in ContributorStack; fix spec makeDoc factories 2026-04-19 19:22:52 +02:00
Marcel
0c65d5d748 feat(dashboard): show contributor pill stack on each mission control queue item
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 19:10:16 +02:00
Marcel
031f6ea29a feat(dashboard): add ContributorStack component for mission control pill stacks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 19:06:46 +02:00
Marcel
43f19ebe87 chore(types): regenerate API types with contributor fields on TranscriptionQueueItemDTO 2026-04-19 19:03:39 +02:00
Marcel
77a4cbd188 test(dashboard): add empty-queue guard and boundary tests for contributor cap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:54:06 +02:00
Marcel
9407cb9dc4 feat(dashboard): add contributors to TranscriptionQueueItemDTO with 5-cap and hasMore flag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:44:26 +02:00
Marcel
80c952cd6c feat(dashboard): expose findContributorsPerDocument in AuditLogQueryService 2026-04-19 18:30:40 +02:00
Marcel
615392216c fix(dashboard): use LEFT JOIN users in findContributorsPerDocument for deleted-user resilience 2026-04-19 18:25:00 +02:00
Marcel
37203e96ab feat(dashboard): add findContributorsPerDocument query and ContributorRow projection 2026-04-19 18:18:26 +02:00
Marcel
10dbce1c70 feat(dashboard): complete frontend redesign for Issue #271
Some checks failed
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Failing after 1m21s
CI / Unit & Component Tests (push) Failing after 2m37s
CI / Unit & Component Tests (pull_request) Failing after 2m27s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 1m21s
- +layout.svelte: Upload button in header (authenticated users only)
- +page.server.ts: call /api/dashboard/resume, /pulse, /activity;
  remove deprecated /api/documents/incomplete and /recent-activity
- +page.svelte: 2-col grid layout (main + 320px sidebar), greeting,
  DashboardFamilyPulse + DashboardActivityFeed in sidebar
- DashboardResumeStrip: refactored to use server data (resumeDoc prop),
  SVG thumbnail, progress bar with aria-*, empty state, CTA
- DashboardFamilyPulse: new component — weekly stats from audit_log
- DashboardActivityFeed: new component — activity feed with "für dich" badge
- Update specs for new data shapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:44:08 +02:00
Marcel
99247ed58d feat(i18n): add dashboard i18n keys (de/en/es)
Greeting, resume card, mission control, family pulse, activity feed,
audit action verbs, and dropzone keys for the Issue #271 dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:13:57 +02:00
Marcel
714f00ef9d chore(types): regenerate API types with dashboard endpoints
Adds DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO,
ActivityActorDTO and the three /api/dashboard/* paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:10:50 +02:00
Marcel
9e0b72bc10 feat(dashboard): remove deprecated /incomplete and /recent-activity endpoints
GET /api/documents/incomplete and GET /api/documents/recent-activity are
superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:05:14 +02:00
Marcel
c678432d25 fix(migration): correct app_users → users table references in V46/V47
The AppUser entity is mapped to the 'users' table (not 'app_users').
V46 had a broken REFERENCES clause and hardcoded role in REVOKE; V47 and the
native query in AuditLogQueryRepository had the same wrong table name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:58:04 +02:00
Marcel
19832dc1e0 refactor(security): extract requireUserId to SecurityUtils
Both DocumentController and TranscriptionBlockController contained
identical private requireUserId helpers. Extracted to a shared static
utility in the security package ahead of DashboardController which
also needs actor resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:39:41 +02:00
Marcel
b3013c42c0 fix(audit): add blockId to TEXT_SAVED audit payload
Required for dashboard Pulse stat 2 (COUNT DISTINCT blockId).
Without it, two saves on different blocks on the same page
were indistinguishable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:36:02 +02:00
Marcel
cb02dc84f6 feat(user): add deterministic avatar color to AppUser
Adds color field assigned from an 8-colour palette keyed on the user's UUID
hash (Math.abs(id.hashCode()) % 8). Fires via @PrePersist/@PreUpdate/@PostLoad
so both new and existing users get the correct colour at runtime.

V47 migration adds the column and fixes the V46 REVOKE bug that hardcoded
role name 'app_user' instead of CURRENT_USER.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:33:27 +02:00
257 changed files with 27846 additions and 3500 deletions

View File

@@ -311,13 +311,15 @@ Save bar pattern — use **sticky full-bleed** for long forms (edit document), *
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
```
Back link pattern:
Back button pattern — use the shared `<BackButton>` component from `$lib/components/BackButton.svelte`:
```svelte
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" .../>
Zurück zur Übersicht
</a>
<script lang="ts">
import BackButton from '$lib/components/BackButton.svelte';
</script>
<BackButton />
```
The component calls `history.back()` so the user returns to wherever they came from. Label is always "Zurück" (no contextual suffix — destination is unknown). Touch target ≥ 44px and focus ring are built in. Do not use a static `<a href>` for back navigation.
Subtle action link (e.g. "new document/person"):
```svelte

View File

@@ -164,12 +164,19 @@
<version>3.0.2</version>
</dependency>
<!-- PDF rendering for training data export -->
<!-- PDF rendering for training data export and thumbnail generation -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.4</version>
</dependency>
<!-- TIFF decoding plugin for ImageIO (thumbnail generation from scanned TIFFs) -->
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-tiff</artifactId>
<version>3.12.0</version>
</dependency>
</dependencies>

View File

@@ -12,4 +12,9 @@ public interface ActivityFeedRow {
UUID getDocumentId();
Instant getHappenedAt();
boolean isYouMentioned();
boolean isYouParticipated();
int getCount();
Instant getHappenedAtUntil();
/** Present only for COMMENT_ADDED and MENTION_CREATED — null otherwise. */
UUID getCommentId();
}

View File

@@ -1,5 +1,7 @@
package org.raddatz.familienarchiv.audit;
import java.util.Set;
public enum AuditKind {
/** Payload: none */
@@ -24,5 +26,10 @@ public enum AuditKind {
COMMENT_ADDED,
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
MENTION_CREATED,
MENTION_CREATED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
);
}

View File

@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -23,36 +24,92 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
@Query(value = """
SELECT * FROM (
SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at))
a.kind AS kind,
a.actor_id AS actorId,
CASE
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
a.document_id AS documentId,
a.happened_at AS happened_at,
(a.kind = 'MENTION_CREATED'
AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned
WITH events AS (
SELECT
a.kind,
a.actor_id,
a.document_id,
a.happened_at,
a.payload,
LAG(a.happened_at) OVER (
PARTITION BY a.actor_id, a.document_id, a.kind
ORDER BY a.happened_at
) AS prev_happened_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_id
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED')
WHERE a.kind IN (:kinds)
AND a.document_id IS NOT NULL
ORDER BY a.actor_id, a.document_id, a.kind,
date_trunc('hour', a.happened_at), a.happened_at DESC
) deduped
ORDER BY happened_at DESC
),
sessions_marked AS (
SELECT
kind, actor_id, document_id, happened_at, payload,
CASE
WHEN kind IN ('COMMENT_ADDED','MENTION_CREATED') THEN 1
WHEN prev_happened_at IS NULL THEN 1
WHEN EXTRACT(EPOCH FROM (happened_at - prev_happened_at)) > 7200 THEN 1
ELSE 0
END AS is_new_session
FROM events
),
sessions AS (
SELECT
kind, actor_id, document_id, happened_at, payload,
SUM(is_new_session) OVER (
PARTITION BY actor_id, document_id, kind
ORDER BY happened_at
ROWS UNBOUNDED PRECEDING
) AS session_id
FROM sessions_marked
),
aggregated AS (
SELECT
s.kind,
s.actor_id,
s.document_id,
s.session_id,
MIN(s.happened_at) AS happened_at,
CASE WHEN COUNT(*) > 1 THEN MAX(s.happened_at) ELSE NULL END AS happened_at_until,
COUNT(*)::int AS count,
BOOL_OR(s.kind = 'MENTION_CREATED'
AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned,
-- COMMENT_ADDED/MENTION_CREATED always have is_new_session=1, so each group has one row and MIN collapses to that row payload
MIN(s.payload::text)::jsonb AS payload
FROM sessions s
GROUP BY s.kind, s.actor_id, s.document_id, s.session_id
)
SELECT
ag.kind AS kind,
ag.actor_id AS actorId,
CASE
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
ag.document_id AS documentId,
ag.happened_at AS happened_at,
ag.you_mentioned AS youMentioned,
-- payload->>'commentId' matches notifications.reference_id per AuditKind.COMMENT_ADDED contract
EXISTS(
SELECT 1 FROM notifications n
WHERE n.type = 'REPLY'
AND n.recipient_id = CAST(:currentUserId AS uuid)
AND n.reference_id = (ag.payload->>'commentId')::uuid
) AS youParticipated,
ag.count AS count,
ag.happened_at_until AS happenedAtUntil,
(ag.payload->>'commentId')::uuid AS commentId
FROM aggregated ag
LEFT JOIN users u ON u.id = ag.actor_id
ORDER BY ag.happened_at DESC
LIMIT :limit
""", nativeQuery = true)
List<ActivityFeedRow> findDedupedActivityFeed(
List<ActivityFeedRow> findRolledUpActivityFeed(
@Param("currentUserId") String currentUserId,
@Param("limit") int limit);
@Param("limit") int limit,
@Param("kinds") Collection<String> kinds);
@Query(value = """
SELECT
@@ -106,4 +163,38 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ORDER BY a.document_id, MIN(a.happened_at)
""", nativeQuery = true)
List<ContributorRow> findContributorsPerDocument(@Param("documentIds") List<UUID> documentIds);
@Query(value = """
SELECT
ranked.document_id AS documentId,
ranked.actorInitials AS actorInitials,
ranked.actorColor AS actorColor,
ranked.actorName AS actorName
FROM (
SELECT
a.document_id,
CASE
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
NULLIF(CONCAT_WS(' ', u.first_name, u.last_name), '') AS actorName,
ROW_NUMBER() OVER (
PARTITION BY a.document_id
ORDER BY MAX(a.happened_at) DESC
) AS rn
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_id
WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED')
AND a.document_id IN :documentIds
AND a.actor_id IS NOT NULL
GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color
) ranked
WHERE ranked.rn <= 4
ORDER BY ranked.document_id, ranked.rn
""", nativeQuery = true)
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
}

View File

@@ -17,7 +17,12 @@ public class AuditLogQueryService {
}
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
return queryRepository.findDedupedActivityFeed(currentUserId.toString(), limit);
return findActivityFeed(currentUserId, limit, AuditKind.ROLLUP_ELIGIBLE);
}
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit, Set<AuditKind> kinds) {
List<String> kindNames = kinds.stream().map(Enum::name).toList();
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, kindNames);
}
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {
@@ -38,7 +43,15 @@ public class AuditLogQueryService {
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
return toContributorMap(queryRepository.findContributorsPerDocument(documentIds));
}
public Map<UUID, List<ActivityActorDTO>> findRecentContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
}
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())

View File

@@ -37,4 +37,19 @@ public class AsyncConfig {
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return executor;
}
@Bean("thumbnailExecutor")
public Executor thumbnailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("Thumbnail-");
// CallerRunsPolicy applies back-pressure to quick-upload batches and admin backfill
// instead of dropping work (shared taskExecutor uses AbortPolicy). Safe because the
// task is dispatched via TransactionSynchronization.afterCommit, which runs on a
// post-commit callback thread without active transaction synchronization.
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}

View File

@@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.MassImportService;
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -23,6 +24,7 @@ public class AdminController {
private final MassImportService massImportService;
private final DocumentService documentService;
private final DocumentVersionService documentVersionService;
private final ThumbnailBackfillService thumbnailBackfillService;
@PostMapping("/trigger-import")
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
@@ -47,4 +49,15 @@ public class AdminController {
int count = documentService.backfillFileHashes();
return ResponseEntity.ok(new BackfillResult(count));
}
@PostMapping("/generate-thumbnails")
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
thumbnailBackfillService.runBackfillAsync();
return ResponseEntity.accepted().body(thumbnailBackfillService.getStatus());
}
@GetMapping("/thumbnail-status")
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> thumbnailStatus() {
return ResponseEntity.ok(thumbnailBackfillService.getStatus());
}
}

View File

@@ -24,67 +24,6 @@ public class CommentController {
private final CommentService commentService;
private final UserService userService;
// ─── General document comments ────────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/comments")
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
return commentService.getCommentsForDocument(documentId);
}
@PostMapping("/api/documents/{documentId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment postDocumentComment(
@PathVariable UUID documentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToDocumentComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Annotation comments ──────────────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
return commentService.getCommentsForAnnotation(annotationId);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment postAnnotationComment(
@PathVariable UUID documentId,
@PathVariable UUID annotationId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToAnnotationComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Block (transcription) comments ────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")

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,10 +14,23 @@ 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;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
@@ -61,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
@RequestMapping("/api/documents")
@RequiredArgsConstructor
@Slf4j
@Validated
public class DocumentController {
private final DocumentService documentService;
@@ -93,6 +108,31 @@ public class DocumentController {
}
}
// --- THUMBNAIL ---
@GetMapping("/{id}/thumbnail")
public ResponseEntity<InputStreamResource> getDocumentThumbnail(@PathVariable UUID id) {
Document doc = documentService.getDocumentById(id);
if (doc.getThumbnailKey() == null) {
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "No thumbnail for document: " + id);
}
try {
FileService.S3FileDownload download = fileService.downloadFile(doc.getThumbnailKey());
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG)
// `private` (not `public`) prevents shared caches from serving one user's
// thumbnail to another (CWE-525). `immutable` is safe because the URL
// carries a ?v=<thumbnailGeneratedAt> cache-buster that changes whenever
// the underlying file is replaced.
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
.body(download.resource());
} catch (FileService.StorageFileNotFoundException e) {
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND,
"Thumbnail missing in storage: " + doc.getThumbnailKey());
}
}
// --- METADATA ---
@GetMapping("/{id}")
public Document getDocument(@PathVariable UUID id) {
@@ -161,6 +201,7 @@ public class DocumentController {
@RequirePermission(Permission.WRITE_ALL)
public QuickUploadResult quickUpload(
@RequestPart(value = "files", required = false) List<MultipartFile> files,
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
Authentication authentication) {
List<Document> created = new ArrayList<>();
List<Document> updated = new ArrayList<>();
@@ -170,14 +211,21 @@ public class DocumentController {
return new QuickUploadResult(created, updated, errors);
}
documentService.validateBatch(files.size(), metadata);
UUID actorId = requireUserId(authentication);
for (MultipartFile file : files) {
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
for (int i = 0; i < files.size(); i++) {
MultipartFile file = files.get(i);
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
continue;
}
try {
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
DocumentService.StoreResult result = metadata != null
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
: documentService.storeDocument(file, actorId);
if (result.isNew()) {
created.add(result.document());
} else {
@@ -189,15 +237,123 @@ public class DocumentController {
}
}
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
actorId, files.size(), totalBytes, metadata != null,
created.size(), updated.size(), errors.size());
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() {
return Map.of("count", documentService.getIncompleteCount());
}
@GetMapping("/incomplete")
@RequirePermission(Permission.WRITE_ALL)
public List<IncompleteDocumentDTO> getIncomplete(
@Parameter(description = "Maximum number of results (server caps at 200)")
@RequestParam(defaultValue = "50") int size) {
return documentService.findIncompleteDocuments(Math.min(size, 200));
}
@GetMapping("/incomplete/next")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
return documentService.findNextIncompleteDocument(excludeId)
.map(ResponseEntity::ok)
@@ -216,14 +372,20 @@ public class DocumentController {
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
// @Max on page guards against overflow when pageable.getOffset() is computed
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
// Hibernate cheerfully turns into an invalid SQL OFFSET.
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
}
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
// defaults to AND, which matches the frontend default and keeps old clients working.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
Pageable pageable = PageRequest.of(page, size);
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
}
// --- TRAINING LABELS ---

View File

@@ -14,5 +14,20 @@ public record ActivityFeedItemDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youParticipated,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count,
@Nullable OffsetDateTime happenedAtUntil,
@Nullable
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
description = "Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
)
UUID commentId,
@Nullable
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
)
UUID annotationId
) {}

View File

@@ -1,6 +1,10 @@
package org.raddatz.familienarchiv.dashboard;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
@@ -9,6 +13,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@RestController
@@ -35,8 +40,12 @@ public class DashboardController {
@GetMapping("/activity")
public List<ActivityFeedItemDTO> getActivity(
Authentication authentication,
@RequestParam(defaultValue = "7") int limit) {
@RequestParam(defaultValue = "7") int limit,
@Parameter(description = "Filter by audit kinds; omit for all rollup-eligible kinds",
array = @ArraySchema(schema = @Schema(implementation = AuditKind.class)))
@RequestParam(required = false) Set<AuditKind> kinds) {
UUID userId = SecurityUtils.requireUserId(authentication, userService);
return dashboardService.getActivity(userId, Math.min(limit, 20));
Set<AuditKind> effectiveKinds = (kinds == null || kinds.isEmpty()) ? AuditKind.ROLLUP_ELIGIBLE : kinds;
return dashboardService.getActivity(userId, Math.min(limit, 40), effectiveKinds);
}
}

View File

@@ -4,12 +4,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.PulseStatsRow;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.service.CommentService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
@@ -32,6 +34,7 @@ public class DashboardService {
private final DocumentService documentService;
private final TranscriptionService transcriptionService;
private final UserService userService;
private final CommentService commentService;
public DashboardResumeDTO getResume(UUID userId) {
Optional<UUID> docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId);
@@ -79,7 +82,7 @@ public class DashboardService {
.toList();
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
totalBlocks, pct, null, collaborators);
totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
}
public DashboardPulseDTO getPulse(UUID userId) {
@@ -108,8 +111,8 @@ public class DashboardService {
);
}
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit) {
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit);
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit, Set<AuditKind> kinds) {
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit, kinds);
List<UUID> docIds = rows.stream()
.map(ActivityFeedRow::getDocumentId)
@@ -125,18 +128,37 @@ public class DashboardService {
log.warn("Activity: failed to bulk-load document titles", e);
}
List<UUID> commentIds = rows.stream()
.map(ActivityFeedRow::getCommentId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
? Map.of()
: commentService.findAnnotationIdsByIds(commentIds);
return rows.stream().map(row -> {
ActivityActorDTO actor = row.getActorId() != null
? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName())
: null;
String docTitle = titleCache.getOrDefault(row.getDocumentId(), "");
OffsetDateTime happenedAtUntil = row.getHappenedAtUntil() != null
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
: null;
UUID commentId = row.getCommentId();
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
return new ActivityFeedItemDTO(
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
actor,
row.getDocumentId(),
docTitle,
row.getHappenedAt().atOffset(ZoneOffset.UTC),
row.isYouMentioned()
row.isYouMentioned(),
row.isYouParticipated(),
row.getCount(),
happenedAtUntil,
commentId,
annotationId
);
}).toList();
}

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,18 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Data
public class DocumentBatchMetadataDTO {
private List<String> titles;
private UUID senderId;
private List<UUID> receiverIds;
private LocalDate documentDate;
private String location;
private List<String> tagNames;
private Boolean metadataComplete;
}

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

@@ -0,0 +1,18 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.model.Document;
import java.util.List;
public record DocumentSearchItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Document document,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int completionPercentage,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors
) {}

View File

@@ -1,35 +1,38 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.model.Document;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Document> documents,
List<DocumentSearchItem> items,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long total,
long totalElements,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Map<UUID, SearchMatchData> matchData
int pageNumber,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageSize,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int totalPages
) {
/**
* Creates a fully-enriched result from documents and their match overlay data.
* Absent map entries (e.g. document deleted between FTS and enrichment) are safe —
* the frontend treats a missing entry as "no match data".
* Single-page convenience factory used by empty-result shortcuts and by tests that
* don't care about paging. Treats the whole list as page 0 of itself.
*/
public static DocumentSearchResult withMatchData(List<Document> documents, Map<UUID, SearchMatchData> matchData) {
return new DocumentSearchResult(documents, documents.size(), matchData);
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
int size = items.size();
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
}
/**
* Creates a result without match data — used for filter-only searches (no text query).
* No pagination yet — the full matched set is always returned.
* When pagination is added, total must come from a DB COUNT query, not list.size().
* Paged factory used by the service when it has a real Pageable + full match count
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
*/
public static DocumentSearchResult of(List<Document> documents) {
return withMatchData(documents, Map.of());
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
int pageSize = pageable.getPageSize();
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
}
}

View File

@@ -2,9 +2,11 @@ package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.UUID;
public record IncompleteDocumentDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime uploadedAt
) {}

View File

@@ -38,6 +38,10 @@ public enum ErrorCode {
/** A mass import is already in progress; only one can run at a time. 409 */
IMPORT_ALREADY_RUNNING,
// --- Thumbnails ---
/** A thumbnail backfill is already in progress; only one can run at a time. 409 */
THUMBNAIL_BACKFILL_ALREADY_RUNNING,
// --- Invites ---
/** The invite code does not exist. 404 */
INVITE_NOT_FOUND,
@@ -105,6 +109,10 @@ public enum ErrorCode {
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
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

@@ -6,8 +6,11 @@ import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
@@ -43,6 +46,20 @@ public class Document {
@Column(name = "file_hash", length = 64)
private String fileHash;
// S3 key of the generated thumbnail (e.g. "thumbnails/{docId}.jpg"); null until generated
@Column(name = "thumbnail_key")
private String thumbnailKey;
@Column(name = "thumbnail_generated_at")
private LocalDateTime thumbnailGeneratedAt;
@Enumerated(EnumType.STRING)
@Column(name = "thumbnail_aspect", length = 16)
private ThumbnailAspect thumbnailAspect;
@Column(name = "page_count")
private Integer pageCount;
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@@ -117,4 +134,19 @@ public class Document {
@Enumerated(EnumType.STRING)
@Builder.Default
private Set<TrainingLabel> trainingLabels = new HashSet<>();
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
// this URL changes whenever the underlying file does. Dropping the query param
// would let browsers serve a stale thumbnail for a year after the file is
// replaced, and shared caches could leak one user's thumbnail to another
// (CWE-525).
@JsonProperty("thumbnailUrl")
public String getThumbnailUrl() {
if (thumbnailKey == null) return null;
String base = "/api/documents/" + id + "/thumbnail";
if (thumbnailGeneratedAt == null) return base;
return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.model;
public enum ThumbnailAspect {
PORTRAIT,
LANDSCAPE
}

View File

@@ -8,10 +8,6 @@ import java.util.UUID;
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
List<DocumentComment> findByParentId(UUID parentId);
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.repository;
import java.util.UUID;
public interface CompletionStatsRow {
UUID getDocumentId();
int getCompletionPercentage();
}

View File

@@ -46,6 +46,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
List<Document> findByFilePathIsNotNullAndThumbnailKeyIsNull();
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);

View File

@@ -5,12 +5,24 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, UUID> {
@Query(value = """
SELECT
b.document_id AS documentId,
ROUND(COUNT(*) FILTER (WHERE b.reviewed = true) * 100.0 / COUNT(*))::int AS completionPercentage
FROM transcription_blocks b
WHERE b.document_id IN :documentIds
GROUP BY b.document_id
""", nativeQuery = true)
List<CompletionStatsRow> findCompletionStatsForDocuments(
@Param("documentIds") Collection<UUID> documentIds);
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);

View File

@@ -8,10 +8,13 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -26,16 +29,15 @@ public class CommentService {
private final UserService userService;
private final NotificationService notificationService;
private final AuditService auditService;
private final TranscriptionService transcriptionService;
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
List<DocumentComment> roots =
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withRepliesAndMentions(roots);
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
if (commentIds == null || commentIds.isEmpty()) return Map.of();
Map<UUID, UUID> result = new HashMap<>();
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
}
return result;
}
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
@@ -46,27 +48,11 @@ public class CommentService {
@Transactional
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
List<UUID> mentionedUserIds, AppUser author) {
TranscriptionBlock block = transcriptionService.getBlock(documentId, blockId);
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.blockId(blockId)
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
logCommentPosted(author, documentId, saved, mentionedUserIds);
return saved;
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.annotationId(annotationId)
.annotationId(block.getAnnotationId())
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))

View File

@@ -3,8 +3,14 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
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;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
@@ -19,7 +25,9 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -59,6 +67,9 @@ public class DocumentService {
private final DocumentVersionService documentVersionService;
private final AnnotationService annotationService;
private final AuditService auditService;
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AuditLogQueryService auditLogQueryService;
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
public record StoreResult(Document document, boolean isNew) {}
@@ -120,9 +131,56 @@ public class DocumentService {
if (wasPlaceholder) {
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
}
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
return new StoreResult(saved, isNew);
}
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
if (fileCount > 50) {
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
}
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
}
}
@Transactional
public StoreResult storeDocumentWithBatchMetadata(
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
StoreResult base = storeDocument(file, actorId);
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
return new StoreResult(documentRepository.save(doc), base.isNew());
}
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
doc.setTitle(metadata.getTitles().get(fileIndex));
}
if (metadata.getSenderId() != null) {
doc.setSender(personService.getById(metadata.getSenderId()));
}
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
}
if (metadata.getDocumentDate() != null) {
doc.setDocumentDate(metadata.getDocumentDate());
}
if (metadata.getLocation() != null) {
doc.setLocation(metadata.getLocation());
}
if (metadata.getMetadataComplete() != null) {
doc.setMetadataComplete(metadata.getMetadataComplete());
}
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
UUID docId = doc.getId();
updateDocumentTags(docId, metadata.getTagNames());
doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
}
return doc;
}
@Transactional
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
String filename = (file != null && !file.isEmpty())
@@ -182,7 +240,8 @@ public class DocumentService {
}
// Datei
if (file != null && !file.isEmpty()) {
boolean fileUploaded = file != null && !file.isEmpty();
if (fileUploaded) {
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
@@ -192,6 +251,9 @@ public class DocumentService {
Document finalDoc = documentRepository.save(doc);
documentVersionService.recordVersion(finalDoc);
if (fileUploaded) {
thumbnailAsyncRunner.dispatchAfterCommit(finalDoc.getId());
}
return finalDoc;
}
@@ -244,7 +306,8 @@ public class DocumentService {
}
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) {
boolean fileReplaced = newFile != null && !newFile.isEmpty();
if (fileReplaced) {
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
@@ -263,26 +326,153 @@ public class DocumentService {
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
}
if (fileReplaced) {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
}
return saved;
}
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;
}
/**
@@ -324,6 +514,7 @@ public class DocumentService {
}
Document saved = documentRepository.save(doc);
documentVersionService.recordVersion(saved);
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
if (wasPlaceholder) {
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
}
@@ -338,38 +529,30 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) {
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
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));
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
// generates an INNER JOIN that silently drops documents with null sender/receivers.
// 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
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
// rank list. Cost scales linearly with match count — acceptable while documents
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
if (sort == DocumentSort.RECEIVER) {
List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortByFirstReceiver(results, dir);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortBySender(results, dir);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
// RELEVANCE: default when text present and no explicit sort given
@@ -382,12 +565,43 @@ public class DocumentService {
.sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList();
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
Sort springSort = resolveSort(sort, dir);
List<Document> results = documentRepository.findAll(spec, springSort);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
// Fast path — push sort + paging into the DB and enrich only the returned slice.
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
Page<Document> page = documentRepository.findAll(spec, pageRequest);
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
}
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
int from = Math.min((int) pageable.getOffset(), sorted.size());
int to = Math.min(from + pageable.getPageSize(), sorted.size());
return sorted.subList(from, to);
}
private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
}
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
List<Document> colorResolved = resolveDocumentTagColors(documents);
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
List<UUID> docIds = colorResolved.stream().map(Document::getId).toList();
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
return colorResolved.stream().map(doc -> new DocumentSearchItem(
doc,
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
completionByDoc.getOrDefault(doc.getId(), 0),
contributorsByDoc.getOrDefault(doc.getId(), List.of())
)).toList();
}
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
return transcriptionBlockQueryService.getCompletionStats(docIds);
}
private Sort resolveSort(DocumentSort sort, String dir) {
@@ -517,7 +731,7 @@ public class DocumentService {
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
return documentRepository.findByMetadataCompleteFalse(pageable)
.stream()
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle(), doc.getCreatedAt()))
.toList();
}

View File

@@ -112,6 +112,27 @@ public class FileService {
}
}
/**
* Opens a streaming download from S3/MinIO. The caller is responsible for
* closing the returned stream — typically via try-with-resources. Preferred
* over {@link #downloadFileBytes(String)} for large files (multi-MB PDFs
* during thumbnail generation) because it avoids loading the entire file
* into heap memory.
*/
public InputStream downloadFileStream(String s3Key) throws IOException {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
return s3Client.getObject(getObjectRequest);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new IOException("Failed to open stream from storage: " + e.getMessage(), e);
}
}
/**
* Generates a presigned URL for downloading an object from S3/MinIO.
* Valid for 1 hour — covers multi-page documents on CPU-only OCR hardware

View File

@@ -59,6 +59,7 @@ public class MassImportService {
private final PersonService personService;
private final TagService tagService;
private final S3Client s3Client;
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
@Value("${app.s3.bucket}")
private String bucketName;
@@ -332,7 +333,10 @@ public class MassImportService {
if (tag != null) doc.getTags().add(tag);
doc.setMetadataComplete(metadataComplete);
documentRepository.save(doc);
Document saved = documentRepository.save(doc);
if (file.isPresent()) {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
}
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
}

View File

@@ -0,0 +1,92 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Bridges document upload paths to asynchronous thumbnail generation. Use
* {@link #dispatchAfterCommit(UUID)} from inside {@code @Transactional} service methods —
* it registers a post-commit hook so the async task only fires when the surrounding
* transaction actually commits, and is silently skipped on rollback. Mirrors
* {@link org.raddatz.familienarchiv.audit.AuditService#logAfterCommit}.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ThumbnailAsyncRunner {
private final DocumentRepository documentRepository;
private final ThumbnailService thumbnailService;
/** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */
private long generateTimeoutSeconds = 30L;
/**
* Registers a post-commit hook that triggers asynchronous thumbnail generation for the
* given document. When no transaction is active the task is dispatched immediately.
* Safe to call from inside {@code @Transactional} service methods.
*/
public void dispatchAfterCommit(UUID documentId) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
generateAsync(documentId);
}
});
} else {
generateAsync(documentId);
}
}
/**
* Runs thumbnail generation on the {@code thumbnailExecutor} pool, wrapped in a watchdog
* timeout so a hung PDFBox render cannot occupy a pool thread indefinitely. Never throws:
* all errors and timeouts are logged and swallowed so upload paths are not affected.
*/
@Async("thumbnailExecutor")
public void generateAsync(UUID documentId) {
Optional<Document> docOpt = documentRepository.findById(documentId);
if (docOpt.isEmpty()) {
log.warn("Thumbnail generation skipped: document not found id={}", documentId);
return;
}
Document doc = docOpt.get();
ExecutorService timeoutWorker = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "Thumbnail-Render-" + documentId);
t.setDaemon(true);
return t;
});
try {
Future<ThumbnailService.Outcome> future = timeoutWorker.submit(
() -> thumbnailService.generate(doc));
try {
future.get(generateTimeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
log.warn("Thumbnail generation timed out after {}s for doc={}",
generateTimeoutSeconds, documentId);
} catch (Exception e) {
log.warn("Thumbnail generation errored for doc={} reason={}",
documentId, e.getMessage());
}
} finally {
timeoutWorker.shutdownNow();
}
}
}

View File

@@ -0,0 +1,104 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
/**
* Sequentially regenerates thumbnails for documents that have a file attached but no
* thumbnail yet. Runs on the {@code thumbnailExecutor} pool — single-threaded iteration
* is intentional: PDFBox + ImageIO are memory-heavy and we cap peak usage by processing
* documents one at a time. Only one backfill can run at a time; concurrent starts are
* rejected with {@link ErrorCode#THUMBNAIL_BACKFILL_ALREADY_RUNNING}.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ThumbnailBackfillService {
public enum State { IDLE, RUNNING, DONE, FAILED }
public record BackfillStatus(
State state,
String message,
int total,
int processed,
int skipped,
int failed,
LocalDateTime startedAt
) {}
private final DocumentRepository documentRepository;
private final ThumbnailService thumbnailService;
private volatile BackfillStatus currentStatus = new BackfillStatus(
State.IDLE, "Kein Backfill gestartet.", 0, 0, 0, 0, null);
public BackfillStatus getStatus() {
return currentStatus;
}
@Async("thumbnailExecutor")
public void runBackfillAsync() {
if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING,
"Thumbnail-Backfill läuft bereits");
}
LocalDateTime startedAt = LocalDateTime.now();
List<Document> docs;
try {
docs = documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull();
} catch (Exception e) {
currentStatus = new BackfillStatus(State.FAILED,
"Backfill fehlgeschlagen: " + e.getMessage(),
0, 0, 0, 0, startedAt);
log.warn("Thumbnail backfill aborted before starting: {}", e.getMessage());
return;
}
int total = docs.size();
currentStatus = new BackfillStatus(State.RUNNING,
"Backfill läuft…", total, 0, 0, 0, startedAt);
log.info("Thumbnail backfill started: total={}", total);
int processed = 0;
int skipped = 0;
int failed = 0;
for (Document doc : docs) {
ThumbnailService.Outcome outcome;
try {
outcome = thumbnailService.generate(doc);
} catch (Exception e) {
log.warn("Thumbnail generation failed for doc={} reason={}",
doc.getId(), e.getMessage());
outcome = ThumbnailService.Outcome.FAILED;
}
switch (outcome) {
case SUCCESS -> processed++;
case SKIPPED -> skipped++;
case FAILED -> failed++;
}
currentStatus = new BackfillStatus(State.RUNNING,
"Backfill läuft…", total, processed, skipped, failed, startedAt);
}
long durationMs = Duration.between(startedAt, LocalDateTime.now()).toMillis();
log.info("Thumbnail backfill complete: total={} processed={} skipped={} failed={} durationMs={}",
total, processed, skipped, failed, durationMs);
currentStatus = new BackfillStatus(State.DONE,
String.format("Fertig: %d erzeugt, %d übersprungen, %d fehlgeschlagen.",
processed, skipped, failed),
total, processed, skipped, failed, startedAt);
}
}

View File

@@ -0,0 +1,233 @@
package org.raddatz.familienarchiv.service;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.io.RandomAccessReadBuffer;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
/**
* Generates JPEG thumbnail previews for documents (PDF first-page or scaled-down image)
* and uploads them to the S3 thumbnails/ prefix. Fire-and-forget from upload paths via
* {@link ThumbnailAsyncRunner}; also invoked by {@link ThumbnailBackfillService} for
* historical documents. Explicitly does not throw — failures are returned as
* {@link Outcome#FAILED} so the backfill can account for them without aborting the run.
*/
@Service
@Slf4j
public class ThumbnailService {
public enum Outcome { SUCCESS, SKIPPED, FAILED }
private static final int THUMBNAIL_WIDTH = 240;
private static final float JPEG_QUALITY = 0.85f;
private static final int PDF_RENDER_DPI = 100;
// Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should
// render in the portrait tile rather than flipping to landscape at 1.01.
private static final float LANDSCAPE_THRESHOLD = 1.1f;
private static final String PDF_CONTENT_TYPE = "application/pdf";
private static final Set<String> IMAGE_CONTENT_TYPES =
Set.of("image/jpeg", "image/png", "image/tiff");
// Deterministic S3 key — `thumbnails/{docId}.jpg`. When a document's file is replaced
// the regenerated thumbnail overwrites this same key via PutObject, so we never
// orphan old thumbnails. The URL-level cache buster is the `thumbnail_generated_at`
// timestamp (see /api/documents/{id}/thumbnail ?v= param).
private static final String THUMBNAIL_KEY_PREFIX = "thumbnails/";
private static final String THUMBNAIL_KEY_SUFFIX = ".jpg";
private final FileService fileService;
private final S3Client s3Client;
private final DocumentRepository documentRepository;
@Value("${app.s3.bucket}")
private String bucketName;
public ThumbnailService(FileService fileService, S3Client s3Client,
DocumentRepository documentRepository) {
this.fileService = fileService;
this.s3Client = s3Client;
this.documentRepository = documentRepository;
}
public Outcome generate(Document doc) {
if (doc.getFilePath() == null) {
log.debug("Document {} has no filePath, skipping thumbnail", doc.getId());
return Outcome.SKIPPED;
}
String contentType = doc.getContentType();
if (contentType == null || !isSupported(contentType)) {
log.warn("Document {} has unsupported contentType {}, skipping thumbnail",
doc.getId(), contentType);
return Outcome.SKIPPED;
}
SourcePreview preview = readSourcePreview(doc, contentType);
if (preview == null
|| preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) {
log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
return Outcome.FAILED;
}
byte[] jpeg = encodeThumbnail(preview.image(), doc.getId());
if (jpeg == null) return Outcome.FAILED;
String thumbnailKey = thumbnailKeyFor(doc.getId());
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
ThumbnailResult result = new ThumbnailResult(
thumbnailKey, aspectOf(preview.image()), preview.pageCount());
return persistThumbnailMetadata(doc, result);
}
private static ThumbnailAspect aspectOf(BufferedImage source) {
float ratio = (float) source.getWidth() / source.getHeight();
return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT;
}
// First-page image + total page count for the source file. Page count is always
// 1 for image uploads; for PDFs it comes straight from PDDocument.
private record SourcePreview(BufferedImage image, int pageCount) {}
// Everything the generate pipeline has already committed to storage and
// now wants stamped onto the Document entity in a single save call.
private record ThumbnailResult(String key, ThumbnailAspect aspect, int pageCount) {}
private static String thumbnailKeyFor(UUID documentId) {
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
}
private SourcePreview readSourcePreview(Document doc, String contentType) {
try {
return PDF_CONTENT_TYPE.equals(contentType)
? renderPdfFirstPage(doc.getFilePath())
: new SourcePreview(readImage(doc.getFilePath()), 1);
} catch (Exception e) {
log.warn("Thumbnail source read failed for doc={} reason={}",
doc.getId(), e.getMessage());
return null;
}
}
private byte[] encodeThumbnail(BufferedImage source, UUID documentId) {
try {
BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH);
return encodeJpeg(scaled, JPEG_QUALITY);
} catch (Exception e) {
log.warn("Thumbnail JPEG encoding failed for doc={} reason={}",
documentId, e.getMessage());
return null;
}
}
private boolean uploadToStorage(String thumbnailKey, byte[] jpeg, UUID documentId) {
try {
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucketName)
.key(thumbnailKey)
.contentType("image/jpeg")
.build(),
RequestBody.fromBytes(jpeg));
return true;
} catch (Exception e) {
log.warn("Thumbnail upload failed for doc={} key={} reason={}",
documentId, thumbnailKey, e.getMessage());
return false;
}
}
private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) {
try {
doc.setThumbnailKey(result.key());
doc.setThumbnailGeneratedAt(LocalDateTime.now());
doc.setThumbnailAspect(result.aspect());
doc.setPageCount(result.pageCount());
documentRepository.save(doc);
return Outcome.SUCCESS;
} catch (Exception e) {
// Thumbnail is already in S3 but the entity update failed. Because the S3
// key is deterministic (thumbnails/{docId}.jpg), the next successful run
// — either a re-upload of this document or the admin backfill — will
// overwrite it cleanly. Logging distinctly so an operator tracking
// backfill totals can spot the database-side issue.
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
doc.getId(), result.key(), e.getMessage());
return Outcome.FAILED;
}
}
private boolean isSupported(String contentType) {
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
}
private SourcePreview renderPdfFirstPage(String s3Key) throws IOException {
try (InputStream in = fileService.downloadFileStream(s3Key);
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
PDFRenderer renderer = new PDFRenderer(pdf);
BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
return new SourcePreview(image, pdf.getNumberOfPages());
}
}
private BufferedImage readImage(String s3Key) throws IOException {
try (InputStream in = fileService.downloadFileStream(s3Key)) {
BufferedImage img = ImageIO.read(in);
if (img == null) {
throw new IOException("No ImageIO reader available for " + s3Key);
}
return img;
}
}
private BufferedImage scaleToWidth(BufferedImage source, int targetWidth) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
int targetHeight = Math.max(1, Math.round((float) targetWidth * sourceHeight / sourceWidth));
BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
g.dispose();
return scaled;
}
private byte[] encodeJpeg(BufferedImage image, float quality) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(quality);
try (ImageOutputStream out = ImageIO.createImageOutputStream(bos)) {
writer.setOutput(out);
writer.write(null, new IIOImage(image, null, null), param);
} finally {
writer.dispose();
}
return bos.toByteArray();
}
}

View File

@@ -0,0 +1,27 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.repository.CompletionStatsRow;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class TranscriptionBlockQueryService {
private final TranscriptionBlockRepository blockRepository;
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
Map<UUID, Integer> result = new HashMap<>();
for (CompletionStatsRow row : blockRepository.findCompletionStatsForDocuments(documentIds)) {
result.put(row.getDocumentId(), row.getCompletionPercentage());
}
return result;
}
}

View File

@@ -23,7 +23,8 @@ spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
file-size-threshold: 2KB
mail:
host: ${MAIL_HOST:}

View File

@@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_reviewed
ON transcription_blocks (document_id, reviewed);

View File

@@ -0,0 +1,7 @@
-- Partial covering index for the session-style activity feed rollup (#285).
-- Matches the WHERE clause of AuditLogQueryRepository.findRolledUpActivityFeed
-- exactly. DESC on happened_at supports the outer ORDER BY without a sort step.
CREATE INDEX idx_audit_log_rollup
ON audit_log (actor_id, document_id, kind, happened_at DESC)
WHERE kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED',
'BLOCK_REVIEWED','COMMENT_ADDED','MENTION_CREATED');

View File

@@ -0,0 +1,39 @@
-- Backfill COMMENT_ADDED and MENTION_CREATED audit events for comments
-- created before audit logging was added in commit 428c63a2.
-- Without these rows the Chronik activity feed (which reads exclusively from
-- audit_log) cannot surface pre-existing comments in "Für dich" or "Alle".
INSERT INTO audit_log (id, happened_at, actor_id, kind, document_id, payload)
SELECT
gen_random_uuid(),
c.created_at,
c.author_id,
'COMMENT_ADDED',
c.document_id,
jsonb_build_object('commentId', c.id::text)
FROM document_comments c
WHERE NOT EXISTS (
SELECT 1 FROM audit_log a
WHERE a.kind = 'COMMENT_ADDED'
AND a.payload->>'commentId' = c.id::text
);
INSERT INTO audit_log (id, happened_at, actor_id, kind, document_id, payload)
SELECT
gen_random_uuid(),
c.created_at,
c.author_id,
'MENTION_CREATED',
c.document_id,
jsonb_build_object(
'commentId', c.id::text,
'mentionedUserId', m.user_id::text
)
FROM comment_mentions m
JOIN document_comments c ON c.id = m.comment_id
WHERE NOT EXISTS (
SELECT 1 FROM audit_log a
WHERE a.kind = 'MENTION_CREATED'
AND a.payload->>'commentId' = c.id::text
AND a.payload->>'mentionedUserId' = m.user_id::text
);

View File

@@ -0,0 +1,24 @@
-- Backfill annotation_id on block comments and their notifications.
--
-- Before the upstream fix, CommentService.postBlockComment did not set
-- DocumentComment.annotationId, so block comments were stored with
-- annotation_id = NULL and every notification built from them inherited
-- that NULL (see NotificationService.notifyMentions/notifyReply).
--
-- The frontend deep-link flow needs annotationId in the URL query string
-- to open the correct annotation panel and scroll to the comment.
-- Without this backfill, previously issued notifications would still
-- carry annotation_id = NULL even after the code fix lands.
UPDATE document_comments dc
SET annotation_id = tb.annotation_id
FROM transcription_blocks tb
WHERE dc.block_id = tb.id
AND dc.annotation_id IS NULL;
UPDATE notifications n
SET annotation_id = dc.annotation_id
FROM document_comments dc
WHERE n.reference_id = dc.id
AND n.annotation_id IS NULL
AND dc.annotation_id IS NOT NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE documents
ADD COLUMN thumbnail_key VARCHAR(255),
ADD COLUMN thumbnail_generated_at TIMESTAMP;

View File

@@ -0,0 +1,8 @@
-- Adds two nullable metadata columns populated by ThumbnailService when it
-- generates the JPEG preview: thumbnail_aspect (PORTRAIT | LANDSCAPE, from the
-- source image w/h ratio with threshold 1.1) and page_count (from PDDocument
-- for PDFs, 1 for image uploads). Both are null until the existing admin
-- backfill endpoint (/api/admin/generate-thumbnails) reruns the service.
ALTER TABLE documents
ADD COLUMN thumbnail_aspect VARCHAR(16),
ADD COLUMN page_count INTEGER;

View File

@@ -0,0 +1,50 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuditLogQueryServiceTest {
@Mock AuditLogQueryRepository queryRepository;
@InjectMocks AuditLogQueryService auditLogQueryService;
@Test
void findActivityFeed_withKinds_passesKindNamesToRepository() {
UUID userId = UUID.randomUUID();
Set<AuditKind> kinds = Set.of(AuditKind.FILE_UPLOADED);
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
.thenReturn(List.of());
List<ActivityFeedRow> result = auditLogQueryService.findActivityFeed(userId, 10, kinds);
assertThat(result).isEmpty();
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
eq(List.of("FILE_UPLOADED")));
}
@Test
void findActivityFeed_twoArg_defaultsToAllRollupEligibleKinds() {
UUID userId = UUID.randomUUID();
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
.thenReturn(List.of());
auditLogQueryService.findActivityFeed(userId, 10);
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
}
}

View File

@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.MassImportService;
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -16,10 +17,13 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -33,6 +37,7 @@ class AdminControllerTest {
@MockitoBean MassImportService massImportService;
@MockitoBean DocumentService documentService;
@MockitoBean DocumentVersionService documentVersionService;
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
@@ -83,4 +88,57 @@ class AdminControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
// ─── POST /api/admin/generate-thumbnails ───────────────────────────────────
@Test
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void generateThumbnails_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN")
void generateThumbnails_returns202_withStatus_whenAdmin() throws Exception {
ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus(
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
when(thumbnailBackfillService.getStatus()).thenReturn(status);
mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.state").value("RUNNING"))
.andExpect(jsonPath("$.total").value(10));
verify(thumbnailBackfillService).runBackfillAsync();
}
// ─── GET /api/admin/thumbnail-status ───────────────────────────────────────
@Test
void thumbnailStatus_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/admin/thumbnail-status"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ADMIN")
void thumbnailStatus_returns200_withCurrentStatus_whenAdmin() throws Exception {
ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus(
ThumbnailBackfillService.State.DONE, "Fertig: 5 erzeugt, 0 übersprungen, 0 fehlgeschlagen.",
5, 5, 0, 0, LocalDateTime.now());
when(thumbnailBackfillService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/thumbnail-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.state").value("DONE"))
.andExpect(jsonPath("$.processed").value(5))
.andExpect(jsonPath("$.total").value(5));
}
}

View File

@@ -40,246 +40,8 @@ class CommentControllerTest {
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
private static final UUID DOC_ID = UUID.randomUUID();
private static final UUID ANN_ID = UUID.randomUUID();
private static final UUID COMMENT_ID = UUID.randomUUID();
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
@Test
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
@Test
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postDocumentComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.content").value("Test comment"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postDocumentComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
@Test
void replyToComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
.authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void replyToComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
.authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
@Test
void editComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void editComment_returns200_whenHasPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
@Test
void deleteComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteComment_returns204_whenAuthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
@Test
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void editComment_returns200_whenHasWriteAllPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
@Test
@WithMockUser
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postAnnotationComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToAnnotationComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void replyToAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── resolveUser — exception branch ──────────────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception {
// findByEmail throws → catch block in resolveUser → author null, saves anyway
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── Block comment endpoints ─────────────────────────────────────────────
@Test
@@ -305,4 +67,138 @@ class CommentControllerTest {
.andExpect(status().isCreated())
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
}
@Test
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void postBlockComment_returns403_whenMissingPermission() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
UUID blockId = UUID.randomUUID();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postBlockComment_stillSucceeds_whenUserServiceThrows() throws Exception {
// findByEmail throws → catch block in resolveUser → author null, saves anyway
UUID blockId = UUID.randomUUID();
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── Block reply endpoints ───────────────────────────────────────────────
@Test
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
UUID blockId = UUID.randomUUID();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID)
.authorName("Anna").content("Reply").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void replyToBlockComment_returns201_whenHasWriteAllPermission() throws Exception {
UUID blockId = UUID.randomUUID();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID)
.authorName("Anna").content("Reply").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── PATCH /api/documents/{documentId}/comments/{commentId} (shared edit) ──
@Test
void editComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void editComment_returns200_whenHasPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void editComment_returns200_whenHasWriteAllPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── DELETE /api/documents/{documentId}/comments/{commentId} (shared) ────
@Test
void deleteComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteComment_returns204_whenAuthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isNoContent());
}
}

View File

@@ -1,15 +1,17 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
@@ -25,10 +27,12 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -40,6 +44,7 @@ import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -66,7 +71,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
@@ -76,13 +81,13 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_withStatusParam_passesItToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
}
@Test
@@ -109,18 +114,18 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_responseContainsTotalCount() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(0))
.andExpect(jsonPath("$.documents").isArray());
.andExpect(jsonPath("$.totalElements").value(0))
.andExpect(jsonPath("$.items").isArray());
}
@Test
@WithMockUser
void search_responseBodyContainsMatchDataKey() throws Exception {
void search_responseBodyItemsContainMatchData() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder()
.id(docId)
@@ -128,18 +133,82 @@ class DocumentControllerTest {
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.build();
var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData(
var matchData = new SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.matchData").isMap())
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet")
.value("Er schrieb einen langen Brief"));
}
// ─── /api/documents/search pagination ─────────────────────────────────────
@Test
@WithMockUser
void search_responseExposesPagingFields() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pageNumber").exists())
.andExpect(jsonPath("$.pageSize").exists())
.andExpect(jsonPath("$.totalPages").exists())
.andExpect(jsonPath("$.totalElements").exists());
}
@Test
@WithMockUser
void search_returns400_whenSizeExceedsMax() throws Exception {
// Locks @Validated on the controller — removing it silently reopens the
// DoS window where a client could request all 1500 docs + enrichment.
mockMvc.perform(get("/api/documents/search").param("size", "101"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenSizeBelowMin() throws Exception {
mockMvc.perform(get("/api/documents/search").param("size", "0"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenPageNegative() throws Exception {
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenPageAboveMax() throws Exception {
// Guards against page * size overflow into negative SQL OFFSET
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_passesPageRequestToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
.andExpect(status().isOk());
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
org.springframework.data.domain.Pageable pageable = captor.getValue();
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
}
// ─── POST /api/documents ─────────────────────────────────────────────────
@Test
@@ -359,6 +428,62 @@ class DocumentControllerTest {
.andExpect(status().isNotFound());
}
// ─── GET /api/documents/{id}/thumbnail ───────────────────────────────────
@Test
void getDocumentThumbnail_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/thumbnail"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentThumbnail_returns404_whenDocHasNoThumbnail() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser
void getDocumentThumbnail_returns200_withPrivateCacheHeader() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
.thumbnailKey("thumbnails/" + id + ".jpg").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
.thenReturn(new FileService.S3FileDownload(
new org.springframework.core.io.InputStreamResource(stream), "image/jpeg"));
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "image/jpeg"))
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.containsString("private")))
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("public"))))
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.containsString("immutable")));
}
@Test
@WithMockUser
void getDocumentThumbnail_returns404_whenStorageObjectMissing() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
.thumbnailKey("thumbnails/" + id + ".jpg").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
.thenThrow(new FileService.StorageFileNotFoundException("not found"));
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
.andExpect(status().isNotFound());
}
// ─── POST /api/documents/quick-upload — null/empty files ─────────────────
@Test
@@ -380,7 +505,7 @@ class DocumentControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void getIncompleteCount_returns200_withCount() throws Exception {
when(documentService.getIncompleteCount()).thenReturn(3L);
@@ -389,14 +514,52 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.count").value(3));
}
// ─── GET /api/documents/incomplete (removed — superseded by dashboard) ────
@Test
@WithMockUser(authorities = "READ_ALL")
void getIncompleteCount_returns403_forReaderOnly() throws Exception {
mockMvc.perform(get("/api/documents/incomplete-count"))
.andExpect(status().isForbidden());
}
// ─── GET /api/documents/incomplete ───────────────────────────────────────
@Test
@WithMockUser
void getIncomplete_endpointRemoved() throws Exception {
// The path hits /{id} and fails UUID conversion — not a 200 anymore
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().is4xxClientError());
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = {"WRITE_ALL"})
void getIncomplete_returns200_forWriter_withDTOList() throws Exception {
UUID id = UUID.randomUUID();
java.time.LocalDateTime uploadedAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
var dto = new org.raddatz.familienarchiv.dto.IncompleteDocumentDTO(id, "Unvollständig", uploadedAt);
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Unvollständig"))
.andExpect(jsonPath("$[0].uploadedAt").exists());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getIncomplete_returns403_forReaderOnly() throws Exception {
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getIncomplete_capsSizeAt200() throws Exception {
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/incomplete").param("size", "9999"))
.andExpect(status().isOk());
verify(documentService).findIncompleteDocuments(200);
}
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
@@ -409,7 +572,7 @@ class DocumentControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "WRITE_ALL")
void getNextIncomplete_returns200_whenNextExists() throws Exception {
UUID excludeId = UUID.randomUUID();
Document next = Document.builder()
@@ -423,7 +586,15 @@ class DocumentControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "READ_ALL")
void getNextIncomplete_returns403_forReaderOnly() throws Exception {
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", UUID.randomUUID().toString()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
UUID excludeId = UUID.randomUUID();
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
@@ -597,4 +768,476 @@ class DocumentControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.editorName").value("Otto"));
}
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).lastName("Müller").build();
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(doc1, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
.thenReturn(new DocumentService.StoreResult(doc2, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
.thenReturn(new DocumentService.StoreResult(doc3, true));
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f3 =
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created.length()").value(3))
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.updated").isEmpty())
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).lastName("Müller").build();
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(existing, false));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(docA, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
.thenReturn(new DocumentService.StoreResult(docB, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
.thenReturn(new DocumentService.StoreResult(docC, true));
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f3 =
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
.andExpect(jsonPath("$.created[1].title").value("Beta"))
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.badRequest(
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
.when(documentService).validateBatch(eq(2), any());
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
.thenReturn(new DocumentService.StoreResult(doc, true));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
.andExpect(status().isOk());
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
.containsExactly("Briefwechsel", "Krieg");
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.badRequest(
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
.when(documentService).validateBatch(eq(51), any());
var builder = multipart("/api/documents/quick-upload");
for (int i = 0; i < 51; i++) {
builder.file(new org.springframework.mock.web.MockMultipartFile(
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
}
mockMvc.perform(builder)
.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

@@ -0,0 +1,94 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
import org.raddatz.familienarchiv.audit.ContributorRow;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class AuditLogQueryRepositoryContributorsTest {
static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
static final UUID USER_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000001");
static final UUID USER_B = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000002");
static final UUID USER_C = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000003");
static final UUID USER_D = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000004");
static final UUID USER_E = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000005");
@Autowired AuditLogQueryRepository auditLogQueryRepository;
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#f00')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void findRecentContributors_returns_contributor_with_initials_and_color() {
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getActorInitials()).isEqualTo("AM");
assertThat(rows.get(0).getActorColor()).isEqualTo("#f00");
}
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#aaa')",
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000002', true, 'b@test.com', 'pw', 'Ben', 'Wolf', '#bbb')",
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000003', true, 'c@test.com', 'pw', 'Clara', 'Zorn', '#ccc')",
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000004', true, 'd@test.com', 'pw', 'Dirk', 'Ott', '#ddd')",
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000005', true, 'e@test.com', 'pw', 'Eva', 'Kern', '#eee')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '5 hours')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000002', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '4 hours')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000003', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '3 hours')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000004', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '2 hours')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000005', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '1 hour')"
})
void findRecentContributors_limits_to_4_most_recent() {
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).hasSize(4);
// Most recent first: E, D, C, B (A is 5th, excluded)
assertThat(rows.get(0).getActorInitials()).isEqualTo("EK");
assertThat(rows.get(1).getActorInitials()).isEqualTo("DO");
assertThat(rows.get(2).getActorInitials()).isEqualTo("CZ");
assertThat(rows.get(3).getActorInitials()).isEqualTo("BW");
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')"
})
void findRecentContributors_returns_empty_when_no_audit_entries() {
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).isEmpty();
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
// Deleted user: ON DELETE SET NULL makes actor_id NULL — query excludes these rows
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('TEXT_SAVED', NULL, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void findRecentContributors_excludes_entries_from_deleted_users() {
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).isEmpty();
}
}

View File

@@ -49,13 +49,16 @@ class AuditLogQueryRepositoryIntegrationTest {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')"
})
void findDedupedActivityFeed_returnsAnnotationEntry() {
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10);
void findRolledUpActivityFeed_returnsAnnotationEntry() {
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10,
List.of("TEXT_SAVED","FILE_UPLOADED","ANNOTATION_CREATED","BLOCK_REVIEWED","COMMENT_ADDED","MENTION_CREATED"));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED");
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getHappenedAt()).isNotNull();
assertThat(rows.get(0).getCount()).isEqualTo(1);
assertThat(rows.get(0).getHappenedAtUntil()).isNull();
}
@Test

View File

@@ -0,0 +1,411 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
@Transactional
class AuditLogQueryRepositoryRolledUpTest {
static final UUID USER_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
static final UUID OTHER_USER_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
static final UUID DOC_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
static final UUID OTHER_DOC_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
static final List<String> ALL_ELIGIBLE_KINDS =
AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList();
private static final ObjectMapper MAPPER = new ObjectMapper();
@Autowired AuditLogQueryRepository auditLogQueryRepository;
@Autowired JdbcTemplate jdbcTemplate;
private NamedParameterJdbcTemplate named() {
return new NamedParameterJdbcTemplate(jdbcTemplate);
}
private void insertUserAndDocs() {
jdbcTemplate.update(
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
USER_ID, "rollup-" + USER_ID + "@test.com");
jdbcTemplate.update(
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
OTHER_USER_ID, "rollup-" + OTHER_USER_ID + "@test.com");
jdbcTemplate.update(
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief A', 'a.pdf', 'PLACEHOLDER')",
DOC_ID);
jdbcTemplate.update(
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief B', 'b.pdf', 'PLACEHOLDER')",
OTHER_DOC_ID);
}
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt) {
insertAuditEvent(actorId, docId, kind, happenedAt, Map.of());
}
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt, Map<String, String> payload) {
String payloadJson;
try {
payloadJson = payload.isEmpty() ? null : MAPPER.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("kind", kind)
.addValue("actor", actorId)
.addValue("doc", docId)
.addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC))
.addValue("payload", payloadJson, java.sql.Types.OTHER);
named().update(
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at, payload) "
+ "VALUES (:kind, :actor, :doc, :t, :payload::jsonb)",
params);
}
private void insertReplyNotification(UUID recipientId, UUID docId, UUID commentId) {
jdbcTemplate.update(
"INSERT INTO notifications (recipient_id, type, document_id, reference_id) VALUES (?, 'REPLY', ?, ?)",
recipientId, docId, commentId);
}
@Test
void rolledUpFeed_combines_same_actor_same_doc_within_2h() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T09:00:00Z");
for (int i = 0; i < 20; i++) {
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
}
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
ActivityFeedRow row = rows.get(0);
assertThat(row.getKind()).isEqualTo("TEXT_SAVED");
assertThat(row.getDocumentId()).isEqualTo(DOC_ID);
assertThat(row.getCount()).isEqualTo(20);
assertThat(row.getHappenedAt()).isEqualTo(base);
assertThat(row.getHappenedAtUntil()).isEqualTo(base.plusSeconds(19 * 480L));
}
@Test
void rolledUpFeed_splits_at_2h_boundary() {
insertUserAndDocs();
Instant sessionOneStart = Instant.parse("2026-04-20T08:00:00Z");
Instant sessionOneLast = sessionOneStart.plusSeconds(600);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneStart);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneLast);
Instant sessionTwoStart = sessionOneLast.plusSeconds(2L * 60L * 60L + 60L);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart.plusSeconds(300));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(2);
assertThat(rows.get(0).getCount()).isEqualTo(2);
assertThat(rows.get(0).getHappenedAt()).isEqualTo(sessionTwoStart);
assertThat(rows.get(1).getCount()).isEqualTo(2);
assertThat(rows.get(1).getHappenedAt()).isEqualTo(sessionOneStart);
}
@Test
void rolledUpFeed_has_no_hard_cap_on_long_session() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T06:00:00Z");
for (int i = 0; i < 30; i++) {
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(i * 60L * 30L));
}
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCount()).isEqualTo(30);
assertThat(rows.get(0).getHappenedAt()).isEqualTo(base);
assertThat(rows.get(0).getHappenedAtUntil()).isEqualTo(base.plusSeconds(29 * 60L * 30L));
}
@Test
void rolledUpFeed_never_rolls_up_COMMENT_ADDED_or_MENTION_CREATED() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base);
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(120));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(3);
assertThat(rows).allSatisfy(r -> {
assertThat(r.getKind()).isEqualTo("COMMENT_ADDED");
assertThat(r.getCount()).isEqualTo(1);
assertThat(r.getHappenedAtUntil()).isNull();
});
}
@Test
void rolledUpFeed_excludes_non_eligible_kinds() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T12:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "STATUS_CHANGED", base);
insertAuditEvent(USER_ID, DOC_ID, "METADATA_UPDATED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(120));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
}
@Test
void rolledUpFeed_exposes_count_and_happenedAtUntil_on_singletons_and_rollups() {
insertUserAndDocs();
Instant rollupStart = Instant.parse("2026-04-20T11:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart);
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(300));
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(900));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(2);
assertThat(rows).anySatisfy(r -> {
assertThat(r.getDocumentId()).isEqualTo(DOC_ID);
assertThat(r.getCount()).isEqualTo(2);
assertThat(r.getHappenedAt()).isEqualTo(rollupStart);
assertThat(r.getHappenedAtUntil()).isEqualTo(rollupStart.plusSeconds(300));
});
assertThat(rows).anySatisfy(r -> {
assertThat(r.getDocumentId()).isEqualTo(OTHER_DOC_ID);
assertThat(r.getCount()).isEqualTo(1);
assertThat(r.getHappenedAt()).isEqualTo(rollupStart.plusSeconds(900));
assertThat(r.getHappenedAtUntil()).isNull();
});
}
@Test
void youParticipated_is_true_when_user_has_reply_notification_for_comment() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
insertReplyNotification(USER_ID, DOC_ID, commentId);
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).anySatisfy(r ->
assertThat(r.isYouParticipated()).isTrue()
);
}
@Test
void youParticipated_is_false_for_comment_with_no_reply_notification() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youParticipated_is_false_when_comment_added_has_no_commentId_in_payload() {
insertUserAndDocs();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of());
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youParticipated_is_false_when_reply_notification_belongs_to_other_user() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
insertReplyNotification(OTHER_USER_ID, DOC_ID, commentId);
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youMentioned_is_true_when_mention_created_payload_matches_current_user() {
insertUserAndDocs();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", USER_ID.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).anySatisfy(r ->
assertThat(r.isYouMentioned()).isTrue()
);
}
@Test
void rolledUpFeed_exposes_commentId_for_COMMENT_ADDED_events() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
}
@Test
void rolledUpFeed_exposes_commentId_for_MENTION_CREATED_events() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
Instant.parse("2026-04-20T10:00:00Z"),
Map.of("commentId", commentId.toString(), "mentionedUserId", USER_ID.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
}
@Test
void rolledUpFeed_commentId_is_null_for_non_comment_kinds() {
insertUserAndDocs();
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("blockId", "ccc", "pageNumber", "1"));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCommentId()).isNull();
}
@Test
void youMentioned_is_false_when_mention_created_payload_targets_different_user() {
insertUserAndDocs();
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", OTHER_USER_ID.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouMentioned()).isFalse()
);
}
// ─── kinds filter ─────────────────────────────────────────────────────────
@Test
void rolledUpFeed_with_single_kind_returns_only_that_kind() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40, List.of("FILE_UPLOADED"));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("FILE_UPLOADED");
}
@Test
void rolledUpFeed_with_multiple_kinds_returns_union() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40, List.of("TEXT_SAVED", "FILE_UPLOADED"));
assertThat(rows).hasSize(2);
assertThat(rows).extracting(ActivityFeedRow::getKind)
.containsExactlyInAnyOrder("TEXT_SAVED", "FILE_UPLOADED");
}
@Test
void rolledUpFeed_with_default_returns_all_six_eligible_kinds() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120));
insertAuditEvent(USER_ID, DOC_ID, "BLOCK_REVIEWED", base.plusSeconds(7300));
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(7360));
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED", base.plusSeconds(7420));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40,
List.of("TEXT_SAVED", "FILE_UPLOADED", "ANNOTATION_CREATED",
"BLOCK_REVIEWED", "COMMENT_ADDED", "MENTION_CREATED"));
assertThat(rows).hasSize(6);
}
@Test
void rolledUpFeed_excludes_rows_not_in_filter_set() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40, List.of("TEXT_SAVED"));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
}
@Test
void rolledUpFeed_rollup_still_works_when_kind_set_is_filtered_to_single_rollable_kind() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T09:00:00Z");
for (int i = 0; i < 10; i++) {
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
}
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(20));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40, List.of("TEXT_SAVED"));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
assertThat(rows.get(0).getCount()).isEqualTo(10);
}
}

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.PermissionAspect;
@@ -15,10 +16,12 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -134,10 +137,79 @@ class DashboardControllerTest {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_clamps_limit_to_40() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity").param("limit", "9999"))
.andExpect(status().isOk());
verify(dashboardService).getActivity(any(UUID.class), org.mockito.ArgumentMatchers.eq(40), any());
}
// ─── GET /api/dashboard/activity — kinds param ───────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_parsesKinds_fromCsvQueryParam() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity")
.param("kinds", "FILE_UPLOADED", "TEXT_SAVED"))
.andExpect(status().isOk());
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.FILE_UPLOADED, AuditKind.TEXT_SAVED)));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_returns400_forUnknownKindValue() throws Exception {
mockMvc.perform(get("/api/dashboard/activity").param("kinds", "INVALID_KIND"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_defaults_to_rollupEligible_whenKindsAbsent() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isOk());
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
org.mockito.ArgumentMatchers.eq(AuditKind.ROLLUP_ELIGIBLE));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_treats_single_valid_kind_as_filter() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity").param("kinds", "COMMENT_ADDED"))
.andExpect(status().isOk());
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.COMMENT_ADDED)));
}
}

View File

@@ -6,21 +6,30 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.PulseStatsRow;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.service.CommentService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
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 org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -33,9 +42,35 @@ class DashboardServiceTest {
@Mock DocumentService documentService;
@Mock TranscriptionService transcriptionService;
@Mock UserService userService;
@Mock CommentService commentService;
@InjectMocks DashboardService dashboardService;
// ─── getResume wires thumbnailUrl from Document ───────────────────────────
@Test
void getResume_populatesThumbnailUrl_fromDocument() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.fromString("12345678-aaaa-bbbb-cccc-1234567890ab");
Document doc = Document.builder()
.id(docId).title("Brief").originalFilename("brief.pdf")
.thumbnailKey("thumbnails/" + docId + ".jpg")
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
.receivers(new HashSet<>())
.build();
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(transcriptionService.listBlocks(docId)).thenReturn(List.of());
DashboardResumeDTO result = dashboardService.getResume(userId);
assertThat(result).isNotNull();
assertThat(result.thumbnailUrl()).isEqualTo(doc.getThumbnailUrl());
assertThat(result.thumbnailUrl()).startsWith("/api/documents/" + docId + "/thumbnail?v=");
}
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
@Test
@@ -79,7 +114,7 @@ class DashboardServiceTest {
UUID docId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "ANNOTATION_CREATED");
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row, row));
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row, row));
Document doc = Document.builder()
.id(docId).title("Familienbrief").originalFilename("f.pdf")
@@ -87,14 +122,101 @@ class DashboardServiceTest {
.build();
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(doc));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items).hasSize(2);
assertThat(items.get(0).documentTitle()).isEqualTo("Familienbrief");
verify(documentService, never()).getDocumentById(docId);
}
// ─── getActivity comment/annotation enrichment ────────────────────────────
@Test
void getActivity_populatesCommentId_forCommentEvents() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items).hasSize(1);
assertThat(items.get(0).commentId()).isEqualTo(commentId);
}
@Test
void getActivity_populatesAnnotationId_viaCommentService() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID annotationId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
.thenReturn(Map.of(commentId, annotationId));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items).hasSize(1);
assertThat(items.get(0).annotationId()).isEqualTo(annotationId);
}
@Test
void getActivity_leavesBothNull_forNonCommentKinds() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items).hasSize(1);
assertThat(items.get(0).commentId()).isNull();
assertThat(items.get(0).annotationId()).isNull();
verify(commentService, never()).findAnnotationIdsByIds(anyList());
}
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
@Test
void pulse_uses_all_rollup_eligible_kinds_never_calls_kinds_filtered_overload() {
UUID userId = UUID.randomUUID();
PulseStatsRow stats = new PulseStatsRow() {
public long getPages() { return 0; }
public long getAnnotated() { return 0; }
public long getTranscribed() { return 0; }
public long getUploaded() { return 0; }
public long getYourPages() { return 0; }
};
when(auditLogQueryService.getPulseStats(any(OffsetDateTime.class), any(UUID.class)))
.thenReturn(stats);
when(auditLogQueryService.findActivityFeed(userId, 50)).thenReturn(List.of());
dashboardService.getPulse(userId);
verify(auditLogQueryService).findActivityFeed(userId, 50);
verify(auditLogQueryService, never()).findActivityFeed(any(UUID.class), anyInt(), any(Set.class));
}
private ActivityFeedRow mockFeedRow(UUID docId, String kind) {
return mockFeedRow(docId, kind, null);
}
private ActivityFeedRow mockFeedRow(UUID docId, String kind, UUID commentId) {
return new ActivityFeedRow() {
public String getKind() { return kind; }
public UUID getActorId() { return null; }
@@ -104,6 +226,10 @@ class DashboardServiceTest {
public UUID getDocumentId() { return docId; }
public Instant getHappenedAt() { return Instant.now(); }
public boolean isYouMentioned() { return false; }
public boolean isYouParticipated() { return false; }
public int getCount() { return 1; }
public Instant getHappenedAtUntil() { return null; }
public UUID getCommentId() { return commentId; }
};
}
}

View File

@@ -2,67 +2,103 @@ package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.springframework.data.domain.PageRequest;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DocumentSearchResultTest {
private Document doc(UUID id) {
return Document.builder()
.id(id)
private DocumentSearchItem item(UUID docId) {
Document doc = Document.builder()
.id(docId)
.title("Test")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.build();
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
}
@Test
void withMatchData_total_equals_list_size() {
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
DocumentSearchResult result = DocumentSearchResult.of(
List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
assertThat(result.totalElements()).isEqualTo(2L);
assertThat(result.pageNumber()).isZero();
assertThat(result.pageSize()).isEqualTo(2);
assertThat(result.totalPages()).isEqualTo(1);
}
@Test
void of_empty_shortcut_has_zero_totalPages() {
DocumentSearchResult result = DocumentSearchResult.of(List.of());
assertThat(result.totalElements()).isZero();
assertThat(result.totalPages()).isZero();
}
@Test
void paged_factory_populates_paging_fields_from_pageable_and_total() {
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
assertThat(result.items()).hasSize(2);
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(1);
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
}
@Test
void paged_factory_totalPages_rounds_up_on_remainder() {
DocumentSearchResult result =
DocumentSearchResult.paged(List.of(), PageRequest.of(0, 7), 30L);
assertThat(result.totalPages()).isEqualTo(5); // ceil(30 / 7)
}
@Test
void of_exposes_items_with_completion_and_contributors() {
UUID id = UUID.randomUUID();
List<Document> docs = List.of(doc(id));
Map<UUID, SearchMatchData> matchData = Map.of(id, SearchMatchData.empty());
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
.status(DocumentStatus.UPLOADED).build();
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData);
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
assertThat(result.total()).isEqualTo(1L);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).completionPercentage()).isEqualTo(75);
assertThat(result.items().get(0).contributors()).containsExactly(actor);
}
@Test
void withMatchData_exposes_match_data_map() {
UUID id = UUID.randomUUID();
SearchMatchData data = new SearchMatchData("snippet", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
DocumentSearchResult result = DocumentSearchResult.withMatchData(List.of(doc(id)), Map.of(id, data));
assertThat(result.matchData()).containsKey(id);
assertThat(result.matchData().get(id).transcriptionSnippet()).isEqualTo("snippet");
}
@Test
void of_factory_returns_empty_match_data() {
UUID id = UUID.randomUUID();
DocumentSearchResult result = DocumentSearchResult.of(List.of(doc(id)));
assertThat(result.matchData()).isEmpty();
assertThat(result.total()).isEqualTo(1L);
}
@Test
void documents_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("documents").getAnnotation(Schema.class);
void items_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("items").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
@Test
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
@Test
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
assertThat(schema).as(name + " must have @Schema").isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
}
}

View File

@@ -0,0 +1,85 @@
package org.raddatz.familienarchiv.model;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DocumentTest {
@Test
void getThumbnailUrl_returnsNull_whenThumbnailKeyNull() {
Document doc = Document.builder()
.id(UUID.randomUUID())
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey(null)
.build();
assertThat(doc.getThumbnailUrl()).isNull();
}
@Test
void getThumbnailUrl_omitsCacheBuster_whenThumbnailKeyPresentButGeneratedAtNull() {
UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555");
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(null)
.build();
assertThat(doc.getThumbnailUrl())
.isEqualTo("/api/documents/" + id + "/thumbnail");
}
@Test
void getThumbnailUrl_includesCacheBuster_whenBothKeyAndGeneratedAtPresent() {
UUID id = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
LocalDateTime generatedAt = LocalDateTime.of(2026, 4, 23, 14, 30, 45);
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(generatedAt)
.build();
// frontend equivalent: `?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`
// where thumbnailGeneratedAt is the ISO-8601 string Jackson serialises.
// LocalDateTime.toString() produces "2026-04-23T14:30:45"; encodeURIComponent
// turns ":" into "%3A" but leaves "T" and digits alone.
String expected = "/api/documents/" + id + "/thumbnail?v=2026-04-23T14%3A30%3A45";
assertThat(doc.getThumbnailUrl()).isEqualTo(expected);
}
@Test
void thumbnailUrl_isSerialisedToJson_soFrontendReceivesIt() throws Exception {
UUID id = UUID.fromString("99999999-aaaa-bbbb-cccc-111122223333");
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
.build();
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
String json = mapper.writeValueAsString(doc);
// Locks the wire contract, not just the Java API: every Document JSON must carry
// `thumbnailUrl`. Protects against silent breakage if the getter gets renamed,
// hidden behind @JsonIgnore, or visibility-reduced — any of which would leave the
// frontend rendering the fallback icon on every surface.
assertThat(json).contains("\"thumbnailUrl\":\"" + doc.getThumbnailUrl() + "\"");
}
}

View File

@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
@@ -65,6 +66,40 @@ class DocumentRepositoryTest {
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
}
// ─── thumbnailAspect + pageCount round-trip ───────────────────────────────
@Test
void save_persistsThumbnailAspectAndPageCount() {
Document document = Document.builder()
.title("Mit Aspekt")
.originalFilename("aspect.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailAspect(ThumbnailAspect.LANDSCAPE)
.pageCount(7)
.build();
Document saved = documentRepository.save(document);
Document found = documentRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
assertThat(found.getPageCount()).isEqualTo(7);
}
@Test
void save_thumbnailAspectAndPageCount_defaultToNull() {
Document document = Document.builder()
.title("Ohne Aspekt")
.originalFilename("no_aspect.pdf")
.status(DocumentStatus.PLACEHOLDER)
.build();
Document saved = documentRepository.save(document);
Document found = documentRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getThumbnailAspect()).isNull();
assertThat(found.getPageCount()).isNull();
}
// ─── findByStatus ─────────────────────────────────────────────────────────
@Test

View File

@@ -302,6 +302,102 @@ class MigrationIntegrationTest {
).isInstanceOf(DataIntegrityViolationException.class);
}
// ─── V53: add thumbnail_aspect + page_count columns to documents ─────────
@Test
void v53_thumbnailAspectColumn_existsAndIsNullable() {
UUID docId = createDocument();
// Column must exist and accept NULL (freshly-created doc has no thumbnail yet)
String aspect = jdbc.queryForObject(
"SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId);
assertThat(aspect).isNull();
}
@Test
void v53_pageCountColumn_existsAndIsNullable() {
UUID docId = createDocument();
Integer pageCount = jdbc.queryForObject(
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
assertThat(pageCount).isNull();
}
@Test
void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() {
UUID docId = createDocument();
int portraitRows = jdbc.update(
"UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId);
assertThat(portraitRows).isEqualTo(1);
int landscapeRows = jdbc.update(
"UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId);
assertThat(landscapeRows).isEqualTo(1);
}
@Test
void v53_pageCountColumn_storesInteger() {
UUID docId = createDocument();
jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId);
Integer stored = jdbc.queryForObject(
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
assertThat(stored).isEqualTo(4);
}
// ─── V51: backfill annotation_id on block comments and notifications ─────
@Test
void v51_backfillsAnnotationIdOnBlockCommentsFromTheirBlocks() {
UUID docId = createDocument();
UUID annotationId = insertAnnotation(docId);
UUID blockId = insertBlock(docId, annotationId);
UUID commentId = insertBlockCommentWithNullAnnotationId(docId, blockId);
jdbc.update(V51_BACKFILL_COMMENTS_SQL);
UUID stored = jdbc.queryForObject(
"SELECT annotation_id FROM document_comments WHERE id = ?",
UUID.class, commentId);
assertThat(stored).isEqualTo(annotationId);
}
@Test
void v51_backfillsAnnotationIdOnNotificationsFromTheirReferencedComment() {
UUID docId = createDocument();
UUID userId = insertUser("recipient-" + UUID.randomUUID() + "@example.com");
UUID annotationId = insertAnnotation(docId);
UUID blockId = insertBlock(docId, annotationId);
UUID commentId = insertBlockCommentWithAnnotationId(docId, blockId, annotationId);
UUID notificationId = insertNotificationWithNullAnnotationId(docId, commentId, userId);
jdbc.update(V51_BACKFILL_NOTIFICATIONS_SQL);
UUID stored = jdbc.queryForObject(
"SELECT annotation_id FROM notifications WHERE id = ?",
UUID.class, notificationId);
assertThat(stored).isEqualTo(annotationId);
}
private static final String V51_BACKFILL_COMMENTS_SQL = """
UPDATE document_comments dc
SET annotation_id = tb.annotation_id
FROM transcription_blocks tb
WHERE dc.block_id = tb.id
AND dc.annotation_id IS NULL
""";
private static final String V51_BACKFILL_NOTIFICATIONS_SQL = """
UPDATE notifications n
SET annotation_id = dc.annotation_id
FROM document_comments dc
WHERE n.reference_id = dc.id
AND n.annotation_id IS NULL
AND dc.annotation_id IS NOT NULL
""";
// ─── helpers ─────────────────────────────────────────────────────────────
private UUID createPerson(String firstName, String lastName) {
@@ -326,4 +422,63 @@ class MigrationIntegrationTest {
em.flush();
return doc.getId();
}
private UUID insertAnnotation(UUID docId) {
UUID id = UUID.randomUUID();
jdbc.update("""
INSERT INTO document_annotations
(id, document_id, page_number, x, y, width, height, color)
VALUES (?, ?, 1, 0.1, 0.1, 0.3, 0.1, '#00C7B1')
""", id, docId);
return id;
}
private UUID insertBlock(UUID docId, UUID annotationId) {
UUID id = UUID.randomUUID();
jdbc.update("""
INSERT INTO transcription_blocks
(id, annotation_id, document_id, text, sort_order)
VALUES (?, ?, ?, '', 0)
""", id, annotationId, docId);
return id;
}
private UUID insertUser(String email) {
UUID id = UUID.randomUUID();
jdbc.update("""
INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention)
VALUES (?, ?, 'hash', true, false, false)
""", id, email);
return id;
}
private UUID insertBlockCommentWithNullAnnotationId(UUID docId, UUID blockId) {
UUID id = UUID.randomUUID();
jdbc.update("""
INSERT INTO document_comments
(id, document_id, block_id, annotation_id, author_name, content)
VALUES (?, ?, ?, NULL, 'Tester', 'Hi')
""", id, docId, blockId);
return id;
}
private UUID insertBlockCommentWithAnnotationId(UUID docId, UUID blockId, UUID annotationId) {
UUID id = UUID.randomUUID();
jdbc.update("""
INSERT INTO document_comments
(id, document_id, block_id, annotation_id, author_name, content)
VALUES (?, ?, ?, ?, 'Tester', 'Hi')
""", id, docId, blockId, annotationId);
return id;
}
private UUID insertNotificationWithNullAnnotationId(UUID docId, UUID commentId, UUID recipientId) {
UUID id = UUID.randomUUID();
jdbc.update("""
INSERT INTO notifications
(id, recipient_id, type, document_id, reference_id, annotation_id, read, actor_name)
VALUES (?, ?, 'MENTION', ?, ?, NULL, false, 'Tester')
""", id, recipientId, docId, commentId);
return id;
}
}

View File

@@ -0,0 +1,104 @@
package org.raddatz.familienarchiv.repository;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TranscriptionBlockRepositoryIntegrationTest {
static final UUID DOC_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
static final UUID DOC_B = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
static final UUID ANN_A = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
static final UUID ANN_B = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
@Autowired TranscriptionBlockRepository repository;
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, true)"
})
void findCompletionStats_returns_100_when_all_blocks_reviewed() {
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_A);
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(100);
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)"
})
void findCompletionStats_returns_0_when_no_blocks_reviewed() {
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(0);
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')"
})
void findCompletionStats_returns_empty_when_document_has_no_blocks() {
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
assertThat(rows).isEmpty();
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 2, false)",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 3, false)"
})
void findCompletionStats_rounds_partial_completion_correctly() {
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(25);
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Doc B', 'b.pdf', 'PLACEHOLDER')",
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 1, 0, 0, 1, 1, '#fff')",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 0, false)"
})
void findCompletionStats_handles_multiple_documents_in_one_call() {
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A, DOC_B));
Map<UUID, Integer> byDoc = rows.stream()
.collect(Collectors.toMap(CompletionStatsRow::getDocumentId, CompletionStatsRow::getCompletionPercentage));
assertThat(byDoc).containsEntry(DOC_A, 100);
assertThat(byDoc).containsEntry(DOC_B, 0);
}
}

View File

@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.CommentRepository;
@@ -39,54 +40,9 @@ class CommentServiceTest {
@Mock UserService userService;
@Mock NotificationService notificationService;
@Mock AuditService auditService;
@Mock TranscriptionService transcriptionService;
@InjectMocks CommentService commentService;
// ─── postComment ──────────────────────────────────────────────────────────
@Test
void postComment_capturesAuthorNameAtWriteTime() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder()
.id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
}
@Test
void postComment_fallsBackToUsername_whenNamesAreBlank() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("hans42");
}
@Test
void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
UUID docId = UUID.randomUUID();
UUID mentionedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build();
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
when(commentRepository.save(any())).thenReturn(saved);
commentService.postComment(docId, null, "Hey @Anna S", List.of(mentionedId), author);
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
}
// ─── replyToComment ───────────────────────────────────────────────────────
@Test
@@ -222,7 +178,7 @@ class CommentServiceTest {
.id(commentId).documentId(docId).authorId(authorId)
.content("Original").authorName("Hans").createdAt(created).build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
stubSaveAssigningRandomId();
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
@@ -282,28 +238,6 @@ class CommentServiceTest {
verify(commentRepository).delete(comment);
}
// ─── getCommentsForDocument ───────────────────────────────────────────────
@Test
void getCommentsForDocument_returnsRootsWithRepliesAttached() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).authorName("Hans").content("Root").build();
DocumentComment reply = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build();
when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId))
.thenReturn(List.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply));
List<DocumentComment> result = commentService.getCommentsForDocument(docId);
assertThat(result).hasSize(1);
assertThat(result.get(0).getReplies()).containsExactly(reply);
}
// ─── replyToComment — reply with null authorId in thread ─────────────────
@Test
@@ -330,82 +264,6 @@ class CommentServiceTest {
verify(notificationService).notifyReply(eq(saved), anySet());
}
// ─── resolveAuthorName edge cases ─────────────────────────────────────────
@Test
void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(" ").lastName(null).build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("user42");
}
@Test
void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(null).lastName(" ").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("user42");
}
@Test
void postComment_includesOnlyFirstName_whenLastNameIsNull() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName("Hans").lastName(null).build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build();
when(commentRepository.save(any())).thenReturn(saved);
commentService.postComment(docId, null, "Hi", List.of(), author);
// first != null && !blank → true; last == null → entire condition false → returns stripped first
verify(commentRepository).save(any());
}
@Test
void postComment_includesOnlyLastName_whenFirstNameIsNull() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(null).lastName("Müller").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build();
when(commentRepository.save(any())).thenReturn(saved);
commentService.postComment(docId, null, "Hi", List.of(), author);
// No exception — name resolution with null first name strips cleanly
verify(commentRepository).save(any());
}
// ─── saveMentions — null/empty guard ─────────────────────────────────────
@Test
void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
.firstName("Hans").lastName("M").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build();
when(commentRepository.save(any())).thenReturn(saved);
commentService.postComment(docId, null, "Hi", null, author);
verify(userService, never()).findAllById(anyList());
}
// ─── collectParticipantIds — non-null authorId in reply ──────────────────
@Test
@@ -459,26 +317,6 @@ class CommentServiceTest {
verify(notificationService).notifyReply(eq(saved), anySet());
}
// ─── getCommentsForAnnotation ─────────────────────────────────────────────
@Test
void getCommentsForAnnotation_returnsRootsForAnnotation() {
UUID annotationId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
DocumentComment root = DocumentComment.builder()
.id(rootId).annotationId(annotationId).authorName("Hans").content("Root").build();
when(commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId))
.thenReturn(List.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
List<DocumentComment> result = commentService.getCommentsForAnnotation(annotationId);
assertThat(result).hasSize(1);
assertThat(result.get(0).getAnnotationId()).isEqualTo(annotationId);
}
// ─── helpers ──────────────────────────────────────────────────────────────
private AppUser buildAdmin() {
@@ -495,65 +333,6 @@ class CommentServiceTest {
// ─── audit: COMMENT_ADDED and MENTION_CREATED ─────────────────────────────
@Test
void postComment_logsCommentAdded() {
UUID docId = UUID.randomUUID();
UUID savedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
DocumentComment saved = DocumentComment.builder()
.id(savedId).documentId(docId).authorName("Hans M").content("Hello").build();
when(commentRepository.save(any())).thenReturn(saved);
commentService.postComment(docId, null, "Hello", List.of(), author);
verify(auditService).logAfterCommit(
eq(AuditKind.COMMENT_ADDED),
eq(author.getId()),
eq(docId),
argThat(p -> savedId.toString().equals(p.get("commentId"))));
}
@Test
void postComment_logsMentionCreated_oncePerMentionedUser() {
UUID docId = UUID.randomUUID();
UUID savedId = UUID.randomUUID();
UUID mentionedId1 = UUID.randomUUID();
UUID mentionedId2 = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build();
AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build();
DocumentComment saved = DocumentComment.builder()
.id(savedId).documentId(docId).authorName("Hans M").content("Hey @Anna @Bob").build();
when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2));
when(commentRepository.save(any())).thenReturn(saved);
commentService.postComment(docId, null, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author);
verify(auditService).logAfterCommit(
eq(AuditKind.MENTION_CREATED),
eq(author.getId()),
eq(docId),
argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId"))));
verify(auditService).logAfterCommit(
eq(AuditKind.MENTION_CREATED),
eq(author.getId()),
eq(docId),
argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId"))));
}
@Test
void postComment_doesNotLogMentionCreated_whenNoMentions() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hello").build();
when(commentRepository.save(any())).thenReturn(saved);
commentService.postComment(docId, null, "Hello", List.of(), author);
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
}
@Test
void replyToComment_logsCommentAdded() {
UUID docId = UUID.randomUUID();
@@ -611,6 +390,8 @@ class CommentServiceTest {
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("B").build();
DocumentComment saved = DocumentComment.builder()
.id(savedId).documentId(docId).blockId(blockId).authorName("Felix B").content("Nice").build();
when(transcriptionService.getBlock(docId, blockId))
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build());
when(commentRepository.save(any())).thenReturn(saved);
commentService.postBlockComment(docId, blockId, "Nice", List.of(), author);
@@ -643,7 +424,10 @@ class CommentServiceTest {
void postBlockComment_setsBlockIdOnComment() {
UUID documentId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
UUID annotationId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
when(transcriptionService.getBlock(documentId, blockId))
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build());
when(commentRepository.save(any())).thenAnswer(inv -> {
DocumentComment c = inv.getArgument(0);
c.setId(UUID.randomUUID());
@@ -657,4 +441,275 @@ class CommentServiceTest {
assertThat(result.getDocumentId()).isEqualTo(documentId);
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
}
@Test
void postBlockComment_setsAnnotationIdFromBlock() {
UUID documentId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
UUID annotationId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
when(transcriptionService.getBlock(documentId, blockId))
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build());
when(commentRepository.save(any())).thenAnswer(inv -> {
DocumentComment c = inv.getArgument(0);
c.setId(UUID.randomUUID());
return c;
});
DocumentComment result = commentService.postBlockComment(
documentId, blockId, "Nice work", List.of(), author);
assertThat(result.getAnnotationId()).isEqualTo(annotationId);
}
@Test
void postBlockComment_propagatesNotFound_whenBlockDoesNotExist() {
UUID documentId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
when(transcriptionService.getBlock(documentId, blockId))
.thenThrow(DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND,
"Transcription block not found: " + blockId));
assertThatThrownBy(() -> commentService.postBlockComment(documentId, blockId, "Hi", List.of(), author))
.isInstanceOf(DomainException.class)
.hasMessageContaining("Transcription block not found");
}
// ─── postBlockComment — authorName resolution ────────────────────────────
@Test
void postBlockComment_capturesAuthorNameAtWriteTime() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder()
.id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build();
stubBlock(docId, blockId);
stubSaveAssigningRandomId();
DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
}
@Test
void postBlockComment_fallsBackToEmail_whenNamesAreBlank() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build();
stubBlock(docId, blockId);
stubSaveAssigningRandomId();
DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("hans42@example.com");
}
@Test
void postBlockComment_fallsBackToEmail_whenFirstNameBlankAndLastNameNull() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(" ").lastName(null).build();
stubBlock(docId, blockId);
stubSaveAssigningRandomId();
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("user42@example.com");
}
@Test
void postBlockComment_fallsBackToEmail_whenFirstNameNullAndLastNameBlank() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(null).lastName(" ").build();
stubBlock(docId, blockId);
stubSaveAssigningRandomId();
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("user42@example.com");
}
@Test
void postBlockComment_usesFirstNameAlone_whenLastNameIsNull() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName("Hans").lastName(null).build();
stubBlock(docId, blockId);
stubSaveAssigningRandomId();
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("Hans");
}
@Test
void postBlockComment_usesLastNameAlone_whenFirstNameIsNull() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(null).lastName("Müller").build();
stubBlock(docId, blockId);
stubSaveAssigningRandomId();
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("Müller");
}
// ─── postBlockComment — mentions ─────────────────────────────────────────
@Test
void postBlockComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
UUID mentionedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna S").build();
stubBlock(docId, blockId);
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
when(commentRepository.save(any())).thenReturn(saved);
commentService.postBlockComment(docId, blockId, "Hey @Anna S", List.of(mentionedId), author);
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
}
@Test
void postBlockComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
.firstName("Hans").lastName("M").build();
stubBlock(docId, blockId);
stubSaveAssigningRandomId();
commentService.postBlockComment(docId, blockId, "Hi", null, author);
verify(userService, never()).findAllById(anyList());
}
@Test
void postBlockComment_logsMentionCreated_oncePerMentionedUser() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
UUID savedId = UUID.randomUUID();
UUID mentionedId1 = UUID.randomUUID();
UUID mentionedId2 = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build();
AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build();
DocumentComment saved = DocumentComment.builder()
.id(savedId).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna @Bob").build();
stubBlock(docId, blockId);
when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2));
when(commentRepository.save(any())).thenReturn(saved);
commentService.postBlockComment(docId, blockId, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author);
verify(auditService).logAfterCommit(
eq(AuditKind.MENTION_CREATED),
eq(author.getId()),
eq(docId),
argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId"))));
verify(auditService).logAfterCommit(
eq(AuditKind.MENTION_CREATED),
eq(author.getId()),
eq(docId),
argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId"))));
}
@Test
void postBlockComment_doesNotLogMentionCreated_whenNoMentions() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hello").build();
stubBlock(docId, blockId);
when(commentRepository.save(any())).thenReturn(saved);
commentService.postBlockComment(docId, blockId, "Hello", List.of(), author);
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
}
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
@Test
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
UUID commentA = UUID.randomUUID();
UUID annotationA = UUID.randomUUID();
UUID commentB = UUID.randomUUID();
UUID annotationB = UUID.randomUUID();
when(commentRepository.findAllById(List.of(commentA, commentB)))
.thenReturn(List.of(
DocumentComment.builder().id(commentA).annotationId(annotationA).build(),
DocumentComment.builder().id(commentB).annotationId(annotationB).build()
));
assertThat(commentService.findAnnotationIdsByIds(List.of(commentA, commentB)))
.containsOnly(
java.util.Map.entry(commentA, annotationA),
java.util.Map.entry(commentB, annotationB)
);
}
@Test
void findAnnotationIdsByIds_returnsEmptyMap_forEmptyInput() {
assertThat(commentService.findAnnotationIdsByIds(List.of())).isEmpty();
verify(commentRepository, never()).findAllById(anyList());
}
@Test
void findAnnotationIdsByIds_omitsUnknownIds() {
UUID known = UUID.randomUUID();
UUID knownAnnotation = UUID.randomUUID();
UUID missing = UUID.randomUUID();
when(commentRepository.findAllById(List.of(known, missing)))
.thenReturn(List.of(
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
));
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
.containsOnly(java.util.Map.entry(known, knownAnnotation))
.doesNotContainKey(missing);
}
@Test
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
UUID legacy = UUID.randomUUID();
UUID block = UUID.randomUUID();
UUID annotation = UUID.randomUUID();
when(commentRepository.findAllById(List.of(legacy, block)))
.thenReturn(List.of(
DocumentComment.builder().id(legacy).annotationId(null).build(),
DocumentComment.builder().id(block).annotationId(annotation).build()
));
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
.containsOnly(java.util.Map.entry(block, annotation))
.doesNotContainKey(legacy);
}
private void stubBlock(UUID docId, UUID blockId) {
when(transcriptionService.getBlock(docId, blockId))
.thenReturn(TranscriptionBlock.builder()
.id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build());
}
private void stubSaveAssigningRandomId() {
when(commentRepository.save(any())).thenAnswer(inv -> {
DocumentComment c = inv.getArgument(0);
if (c.getId() == null) c.setId(UUID.randomUUID());
return c;
});
}
}

View File

@@ -0,0 +1,137 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
* Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
* UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
* against the actual JPA query.
*
* <p>Closes the integration-coverage gap Sara flagged on PR #316.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class DocumentSearchPagedIntegrationTest {
private static final int FIXTURE_SIZE = 120;
@MockitoBean S3Client s3Client;
@Autowired DocumentService documentService;
@Autowired DocumentRepository documentRepository;
@BeforeEach
void seed() {
// Deterministic date spread so DATE-DESC order is predictable:
// document #0 has the oldest date, document #119 has the newest.
for (int i = 0; i < FIXTURE_SIZE; i++) {
Document doc = Document.builder()
.title("Dok-" + String.format("%03d", i))
.originalFilename("dok-" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
.build();
documentRepository.save(doc);
}
assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
}
@Test
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
assertThat(result.pageNumber()).isZero();
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
}
@Test
void search_lastPartialPage_returnsRemainingItems() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(2, 50));
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
assertThat(result.items()).hasSize(20);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
assertThat(result.pageNumber()).isEqualTo(2);
}
@Test
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(99, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
}
@Test
void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
// SENDER sort path fetches all + sorts + slices in-memory (see scaling
// comment in DocumentService). Proves that the in-memory slice path
// returns the correct total from a real repository fetch.
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null,
PageRequest.of(1, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
assertThat(result.pageNumber()).isEqualTo(1);
assertThat(result.totalPages()).isEqualTo(3);
}
@Test
void search_differentPagesReturnDisjointSlices() {
DocumentSearchResult page0 = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50));
DocumentSearchResult page1 = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(1, 50));
// No document id should appear on both pages — slicing must be exclusive.
var idsOnPage0 = page0.items().stream()
.map(item -> item.document().getId())
.toList();
var idsOnPage1 = page1.items().stream()
.map(item -> item.document().getId())
.toList();
for (UUID id : idsOnPage0) {
assertThat(idsOnPage1).doesNotContain(id);
}
}
}

View File

@@ -5,12 +5,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate;
@@ -24,12 +26,16 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DocumentServiceSortTest {
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
@Mock DocumentRepository documentRepository;
@Mock PersonService personService;
@Mock FileService fileService;
@Mock TagService tagService;
@Mock DocumentVersionService documentVersionService;
@Mock AnnotationService annotationService;
@Mock AuditLogQueryService auditLogQueryService;
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@InjectMocks DocumentService documentService;
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
@@ -48,16 +54,16 @@ class DocumentServiceSortTest {
// FTS returns id1 first (higher rank), id2 second
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
// findAll(spec, sort) — the correct date path — returns date-DESC order
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(newer, older));
// findAll(spec, pageable) — the correct date path — returns date-DESC order
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(newer, older)));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
assertThat(result.documents()).hasSize(2);
assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first
assertThat(result.items()).hasSize(2);
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
}
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
@@ -75,10 +81,10 @@ class DocumentServiceSortTest {
.thenReturn(List.of(doc2, doc1)); // unordered from DB
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
// Expect: rank order restored (id1 first)
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
@Test
@@ -94,8 +100,8 @@ class DocumentServiceSortTest {
.thenReturn(List.of(doc2, doc1));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null, null);
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
}

View File

@@ -7,13 +7,16 @@ 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.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
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;
@@ -22,6 +25,7 @@ import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.mock.web.MockMultipartFile;
@@ -44,6 +48,12 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DocumentServiceTest {
// Used by tests that don't care about paging. 10 000 is chosen large enough
// to hold any fixture in this file but small enough that totalPages math
// stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
// red flag — use this constant.
private static final Pageable UNPAGED = PageRequest.of(0, 10_000);
@Mock DocumentRepository documentRepository;
@Mock PersonService personService;
@Mock FileService fileService;
@@ -51,6 +61,9 @@ class DocumentServiceTest {
@Mock DocumentVersionService documentVersionService;
@Mock AnnotationService annotationService;
@Mock AuditService auditService;
@Mock AuditLogQueryService auditLogQueryService;
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
@InjectMocks DocumentService documentService;
// ─── deleteDocument ───────────────────────────────────────────────────────
@@ -253,6 +266,107 @@ class DocumentServiceTest {
verify(documentVersionService).recordVersion(any(Document.class));
}
// ─── thumbnail dispatch ───────────────────────────────────────────────────
@Test
void storeDocument_dispatchesThumbnailAfterSave() throws Exception {
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
UUID savedId = UUID.randomUUID();
Document saved = Document.builder().id(savedId).originalFilename("new.pdf").build();
when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenReturn(saved);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
documentService.storeDocument(file, null);
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
}
@Test
void createDocument_dispatchesThumbnail_onlyWhenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("No file");
UUID savedId = UUID.randomUUID();
Document saved = Document.builder().id(savedId).title("No file")
.originalFilename("No file").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
documentService.createDocument(dto, null);
verifyNoInteractions(thumbnailAsyncRunner);
}
@Test
void createDocument_dispatchesThumbnail_whenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("With file");
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
UUID savedId = UUID.randomUUID();
Document saved = Document.builder().id(savedId).title("With file")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
when(fileService.uploadFile(any(), any()))
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
documentService.createDocument(dto, file);
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
}
@Test
void updateDocument_dispatchesThumbnail_onlyWhenFileReplaced() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Doc").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
documentService.updateDocument(id, new DocumentUpdateDTO(), null, null);
verifyNoInteractions(thumbnailAsyncRunner);
}
@Test
void updateDocument_dispatchesThumbnail_whenNewFileProvided() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Doc").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
org.springframework.mock.web.MockMultipartFile newFile =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
when(fileService.uploadFile(any(), any()))
.thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile, null);
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
}
@Test
void attachFile_dispatchesThumbnailAfterSave() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Placeholder").originalFilename("placeholder")
.status(DocumentStatus.PLACEHOLDER).build();
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
when(fileService.uploadFile(any(), any()))
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
documentService.attachFile(id, file, null);
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
}
// ─── storeDocument ───────────────────────────────────────────────────────
@Test
@@ -386,6 +500,22 @@ class DocumentServiceTest {
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
}
@Test
void findIncompleteDocuments_mapsUploadedAtFromCreatedAt() {
java.time.LocalDateTime createdAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
Document doc = Document.builder()
.id(UUID.randomUUID())
.title("Recent")
.createdAt(createdAt)
.build();
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(doc)));
List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
assertThat(result.get(0).uploadedAt()).isEqualTo(createdAt);
}
@Test
void findIncompleteDocuments_passesSizeToPageable() {
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
@@ -1201,26 +1331,124 @@ class DocumentServiceTest {
assertThat(result).isNull();
}
// ─── searchDocuments — pagination ────────────────────────────────────────
@Test
void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
org.springframework.data.domain.PageRequest.of(1, 50));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}
@Test
void searchDocuments_fastPath_propagatesPageableToDatabase() {
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
org.springframework.data.domain.PageRequest.of(3, 25));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
assertThat(captor.getValue().getPageSize()).isEqualTo(25);
}
@Test
void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
// The service MUST report the full match count from Page.getTotalElements(),
// not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
org.springframework.data.domain.PageRequest.of(0, 50));
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isZero();
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
assertThat(result.items()).hasSize(1); // only the slice is enriched
}
@Test
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
// back with totalElements = 120.
List<Document> all = new java.util.ArrayList<>();
for (int i = 0; i < 120; i++) {
Person p = Person.builder()
.id(UUID.randomUUID())
.firstName("F" + i)
.lastName(String.format("L%03d", i))
.build();
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
}
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
org.springframework.data.domain.PageRequest.of(1, 50));
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(1);
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3);
assertThat(result.items()).hasSize(50);
// Page 1 (offset 50) under ascending sender sort should start at L050
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
}
@Test
void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
// Guards the JPA edge case where page * size > totalElements.
// Must not throw, must return empty content + correct totalElements.
List<Document> all = new java.util.ArrayList<>();
for (int i = 0; i < 30; i++) {
Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
}
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
org.springframework.data.domain.PageRequest.of(10, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(30L);
}
// ─── searchDocuments — status filter ─────────────────────────────────────
@Test
void searchDocuments_passesStatusSpecificationToRepository() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null);
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
}
@Test
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null);
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
}
// ─── getRecentActivity ────────────────────────────────────────────────────
@@ -1296,10 +1524,10 @@ class DocumentServiceTest {
.thenReturn(List.of(withSender, noSender));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
assertThat(result.documents()).hasSize(2);
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
assertThat(result.items()).hasSize(2);
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
}
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
@@ -1316,9 +1544,9 @@ class DocumentServiceTest {
.thenReturn(List.of(noReceivers, withReceiver));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
assertThat(result.documents()).extracting(Document::getTitle)
assertThat(result.items()).extracting(item -> item.document().getTitle())
.containsExactly("Has Receiver", "No Receivers");
}
@@ -1338,10 +1566,10 @@ class DocumentServiceTest {
.thenReturn(List.of(docNullName, docSmith));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.documents()).extracting(Document::getTitle)
assertThat(result.items()).extracting(item -> item.document().getTitle())
.containsExactly("smith doc", "Null lastname doc");
}
@@ -1360,23 +1588,24 @@ class DocumentServiceTest {
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
assertThat(result.matchData()).containsKey(docId);
SearchMatchData md = result.matchData().get(docId);
assertThat(result.items()).hasSize(1);
SearchMatchData md = result.items().get(0).matchData();
assertThat(md.titleOffsets()).hasSize(1);
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
}
@Test
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, null, null,
UNPAGED);
assertThat(result.matchData()).isEmpty();
assertThat(result.items()).isEmpty();
}
@Test
@@ -1393,9 +1622,9 @@ class DocumentServiceTest {
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
SearchMatchData md = result.matchData().get(docId);
SearchMatchData md = result.items().get(0).matchData();
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
}
@@ -1585,4 +1814,437 @@ class DocumentServiceTest {
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
}
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
private MockMultipartFile pdfFile(String name) {
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
}
private void stubStoreDocument(String filename) throws Exception {
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
}
@Test
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
stubStoreDocument("scan01.pdf");
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
}
@Test
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
UUID senderId = UUID.randomUUID();
stubStoreDocument("scan02.pdf");
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person sender = Person.builder().id(senderId).firstName("Anna").build();
when(personService.getById(senderId)).thenReturn(sender);
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
meta.setSenderId(senderId);
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
assertThat(result.document().getSender()).isEqualTo(sender);
}
@Test
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
UUID docId = UUID.randomUUID();
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
when(documentRepository.save(any())).thenAnswer(inv -> {
Document d = inv.getArgument(0);
if (d.getId() == null) d.setId(docId);
return d;
});
when(documentRepository.findById(docId)).thenAnswer(inv -> {
Document d = new Document();
d.setId(docId);
return Optional.of(d);
});
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
when(tagService.findOrCreate("Familie")).thenReturn(tag);
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
meta.setTagNames(List.of("Familie"));
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
verify(tagService).findOrCreate("Familie");
}
@Test
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
stubStoreDocument("scan04.pdf");
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
meta.setTitles(List.of("Only One Title"));
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
assertThat(result.document().getTitle()).isEqualTo("scan04");
}
// ─── validateBatch ───────────────────────────────────────────────────────
@Test
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
assertThatThrownBy(() -> documentService.validateBatch(51, null))
.isInstanceOf(DomainException.class)
.hasMessageContaining("50");
}
@Test
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
documentService.validateBatch(50, null);
}
@Test
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
metadata.setTitles(java.util.List.of("A", "B", "C"));
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
.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

@@ -197,4 +197,39 @@ class FileServiceTest {
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to download");
}
// ─── downloadFileStream ────────────────────────────────────────────────────
@Test
void downloadFileStream_returnsStreamableContent() throws IOException {
byte[] content = "streamed bytes".getBytes();
GetObjectResponse response = GetObjectResponse.builder().contentType("application/pdf").build();
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
try (java.io.InputStream result = fileService.downloadFileStream("documents/file.pdf")) {
assertThat(result.readAllBytes()).isEqualTo(content);
}
}
@Test
void downloadFileStream_throwsStorageFileNotFoundException_whenNoSuchKey() {
NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
assertThatThrownBy(() -> fileService.downloadFileStream("missing/key.pdf"))
.isInstanceOf(FileService.StorageFileNotFoundException.class)
.hasMessageContaining("missing/key.pdf");
}
@Test
void downloadFileStream_throwsIOException_whenS3Exception() {
S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
assertThatThrownBy(() -> fileService.downloadFileStream("documents/file.pdf"))
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to open stream");
}
}

View File

@@ -39,12 +39,13 @@ class MassImportServiceTest {
@Mock PersonService personService;
@Mock TagService tagService;
@Mock S3Client s3Client;
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
MassImportService service;
@BeforeEach
void setUp() {
service = new MassImportService(documentRepository, personService, tagService, s3Client);
service = new MassImportService(documentRepository, personService, tagService, s3Client, thumbnailAsyncRunner);
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
ReflectionTestUtils.setField(service, "colIndex", 0);
ReflectionTestUtils.setField(service, "colBox", 1);

View File

@@ -0,0 +1,118 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class ThumbnailAsyncRunnerTest {
private DocumentRepository documentRepository;
private ThumbnailService thumbnailService;
private ThumbnailAsyncRunner runner;
@BeforeEach
void setUp() {
documentRepository = mock(DocumentRepository.class);
thumbnailService = mock(ThumbnailService.class);
runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService);
}
@Test
void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
runner.dispatchAfterCommit(id);
verify(thumbnailService).generate(doc);
}
@Test
void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
TransactionSynchronizationManager.initSynchronization();
try {
runner.dispatchAfterCommit(id);
// Nothing fired yet — registered, not executed
verify(thumbnailService, never()).generate(any());
// Simulate commit
ArgumentCaptor<TransactionSynchronization> captor =
ArgumentCaptor.forClass(TransactionSynchronization.class);
assertThat(TransactionSynchronizationManager.getSynchronizations()).hasSize(1);
TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit();
verify(thumbnailService).generate(doc);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void dispatchAfterCommit_whenRollback_doesNotDispatch() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
TransactionSynchronizationManager.initSynchronization();
try {
runner.dispatchAfterCommit(id);
// Simulate rollback — afterCompletion with STATUS_ROLLED_BACK, no afterCommit fired
TransactionSynchronizationManager.getSynchronizations().get(0)
.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
verify(thumbnailService, never()).generate(any());
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void generateAsync_skipsWhenDocumentMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
runner.generateAsync(id);
verifyNoInteractions(thumbnailService);
}
@Test
void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
// generate sleeps longer than the timeout — simulates a hung PDFBox render
when(thumbnailService.generate(doc)).thenAnswer(inv -> {
Thread.sleep(5_000);
return ThumbnailService.Outcome.SUCCESS;
});
// Shrink timeout for the test
ReflectionTestUtils.setField(runner, "generateTimeoutSeconds", 1L);
long start = System.currentTimeMillis();
runner.generateAsync(id);
long elapsed = System.currentTimeMillis() - start;
// Must return before the 5s sleep — within ~2s with timeout=1s plus overhead
assertThat(elapsed).isLessThan(3_000);
}
}

View File

@@ -0,0 +1,154 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class ThumbnailBackfillServiceTest {
private DocumentRepository documentRepository;
private ThumbnailService thumbnailService;
private ThumbnailBackfillService backfillService;
@BeforeEach
void setUp() {
documentRepository = mock(DocumentRepository.class);
thumbnailService = mock(ThumbnailService.class);
backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService);
}
@Test
void initialStatus_isIdle() {
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.IDLE);
assertThat(status.total()).isZero();
assertThat(status.processed()).isZero();
assertThat(status.startedAt()).isNull();
}
@Test
void runBackfillAsync_processesAllDocumentsAndFinishesDone() {
Document a = doc();
Document b = doc();
Document c = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b, c));
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
assertThat(status.total()).isEqualTo(3);
assertThat(status.processed()).isEqualTo(3);
assertThat(status.skipped()).isZero();
assertThat(status.failed()).isZero();
verify(thumbnailService, times(3)).generate(any());
}
@Test
void runBackfillAsync_countsSkippedSeparately() {
Document a = doc();
Document b = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b));
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
assertThat(status.processed()).isEqualTo(1);
assertThat(status.skipped()).isEqualTo(1);
assertThat(status.failed()).isZero();
}
@Test
void runBackfillAsync_continuesAfterFailureAndCountsIt() {
Document a = doc();
Document b = doc();
Document c = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b, c));
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED);
when(thumbnailService.generate(c)).thenReturn(ThumbnailService.Outcome.SUCCESS);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
assertThat(status.processed()).isEqualTo(2);
assertThat(status.failed()).isEqualTo(1);
verify(thumbnailService, times(3)).generate(any());
}
@Test
void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() {
Document a = doc();
Document b = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b));
when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom"));
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
assertThat(status.processed()).isEqualTo(1);
assertThat(status.failed()).isEqualTo(1);
}
@Test
void runBackfillAsync_rejectsConcurrentStart() {
// Force state=RUNNING via reflection
ThumbnailBackfillService.BackfillStatus running = new ThumbnailBackfillService.BackfillStatus(
ThumbnailBackfillService.State.RUNNING, "running", 10, 5, 0, 0, LocalDateTime.now());
ReflectionTestUtils.setField(backfillService, "currentStatus", running);
assertThatThrownBy(() -> backfillService.runBackfillAsync())
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING));
}
@Test
void runBackfillAsync_setsStartedAtAndMessage() {
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(doc()));
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.startedAt()).isAfter(before);
assertThat(status.message()).isNotBlank();
}
private Document doc() {
return Document.builder()
.id(UUID.randomUUID())
.title("t")
.originalFilename("f.pdf")
.filePath("documents/f.pdf")
.contentType("application/pdf")
.build();
}
}

View File

@@ -0,0 +1,142 @@
package org.raddatz.familienarchiv.service;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.repository.DocumentRepository;
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.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Full round-trip integration test against real MinIO and real Postgres. Catches S3
* signing / presigning issues that a mocked S3Client would miss — the rest of the
* test pyramid mocks at the FileService boundary.
*/
@SpringBootTest
@Import(PostgresContainerConfig.class)
class ThumbnailServiceIntegrationTest {
private static final String BUCKET = "archive-documents";
private static final String ACCESS_KEY = "minioadmin";
private static final String SECRET_KEY = "minioadmin";
static GenericContainer<?> minio = new GenericContainer<>("minio/minio:RELEASE.2024-06-13T22-53-53Z")
.withEnv("MINIO_ROOT_USER", ACCESS_KEY)
.withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY)
.withCommand("server /data")
.withExposedPorts(9000);
static {
minio.start();
}
@DynamicPropertySource
static void s3Properties(DynamicPropertyRegistry registry) {
registry.add("app.s3.endpoint", () -> "http://" + minio.getHost() + ":" + minio.getMappedPort(9000));
registry.add("app.s3.access-key", () -> ACCESS_KEY);
registry.add("app.s3.secret-key", () -> SECRET_KEY);
registry.add("app.s3.bucket", () -> BUCKET);
registry.add("app.s3.region", () -> "eu-central-1");
}
@Autowired S3Client s3Client;
@Autowired ThumbnailService thumbnailService;
@Autowired DocumentRepository documentRepository;
@Test
void generate_writesDecodableJpegToMinio_readbackMatches() throws IOException {
// Ensure bucket exists (the real app has a bootstrap container for this; in tests we do it here).
// Re-creating is a no-op; wrap in try/catch because the SDK throws on "already owned".
try (S3Client bootstrap = buildClient()) {
try {
bootstrap.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
} catch (Exception ignored) {
// already exists
}
}
// Persist first so Hibernate assigns the UUID — avoids StaleObjectState on a pre-set id
Document persisted = documentRepository.save(Document.builder()
.title("IT Doc")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.contentType("application/pdf")
.build());
UUID docId = persisted.getId();
String pdfKey = "documents/" + docId + "_test.pdf";
s3Client.putObject(PutObjectRequest.builder()
.bucket(BUCKET)
.key(pdfKey)
.contentType("application/pdf")
.build(),
RequestBody.fromBytes(createSamplePdf()));
persisted.setFilePath(pdfKey);
persisted = documentRepository.save(persisted);
ThumbnailService.Outcome outcome = thumbnailService.generate(persisted);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
Document reloaded = documentRepository.findById(docId).orElseThrow();
assertThat(reloaded.getThumbnailKey()).isEqualTo("thumbnails/" + docId + ".jpg");
assertThat(reloaded.getThumbnailGeneratedAt()).isNotNull();
// Read back from MinIO and verify it decodes as a JPEG of the expected width
try (InputStream in = s3Client.getObject(GetObjectRequest.builder()
.bucket(BUCKET).key(reloaded.getThumbnailKey()).build())) {
byte[] jpegBytes = in.readAllBytes();
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(jpegBytes));
assertThat(decoded).isNotNull();
assertThat(decoded.getWidth()).isEqualTo(240);
}
}
private static S3Client buildClient() {
return S3Client.builder()
.endpointOverride(URI.create("http://" + minio.getHost() + ":" + minio.getMappedPort(9000)))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.region(Region.of("eu-central-1"))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
.build();
}
private static byte[] createSamplePdf() throws IOException {
try (PDDocument pdf = new PDDocument()) {
pdf.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream bos = new ByteArrayOutputStream();
pdf.save(bos);
return bos.toByteArray();
}
}
}

View File

@@ -0,0 +1,367 @@
package org.raddatz.familienarchiv.service;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
class ThumbnailServiceTest {
private FileService fileService;
private S3Client s3Client;
private DocumentRepository documentRepository;
private ThumbnailService thumbnailService;
@BeforeEach
void setUp() {
fileService = mock(FileService.class);
s3Client = mock(S3Client.class);
documentRepository = mock(DocumentRepository.class);
thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository);
ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket");
when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0));
}
@Test
void generate_returnsSkipped_whenDocumentHasNoFilePath() {
Document doc = makeDoc("application/pdf", null);
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
verifyNoInteractions(s3Client);
assertThat(doc.getThumbnailKey()).isNull();
}
@Test
void generate_returnsSkipped_forUnsupportedContentType() throws IOException {
Document doc = makeDoc("application/msword", "documents/letter.doc");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(new byte[]{1, 2, 3}));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
verifyNoInteractions(s3Client);
assertThat(doc.getThumbnailKey()).isNull();
}
@Test
void generate_rendersPdf_uploadsJpeg_updatesEntity() throws IOException {
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
byte[] pdfBytes = createSamplePdf();
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(pdfBytes));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
ArgumentCaptor<PutObjectRequest> putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
verify(s3Client).putObject(putCaptor.capture(), bodyCaptor.capture());
PutObjectRequest req = putCaptor.getValue();
assertThat(req.bucket()).isEqualTo("test-bucket");
assertThat(req.key()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
assertThat(req.contentType()).isEqualTo("image/jpeg");
byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
assertThat(jpg).isNotNull();
assertThat(jpg.getWidth()).isEqualTo(240);
assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
verify(documentRepository).save(doc);
}
@Test
void generate_rendersPng_uploadsJpegAtWidth240() throws IOException {
Document doc = makeDoc("image/png", "documents/scan.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
assertThat(jpg.getWidth()).isEqualTo(240);
assertThat(jpg.getHeight()).isEqualTo(320); // 600x800 -> 240x320
}
@Test
void generate_rendersJpeg_uploadsScaledJpeg() throws IOException {
Document doc = makeDoc("image/jpeg", "documents/photo.jpg");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(
readAll(bodyCaptor.getValue().contentStreamProvider().newStream())));
assertThat(jpg.getWidth()).isEqualTo(240);
assertThat(jpg.getHeight()).isEqualTo(120); // 800x400 -> 240x120
}
@Test
void generate_returnsFailed_whenS3PutThrows() throws IOException {
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenThrow((S3Exception) S3Exception.builder().message("quota exceeded").statusCode(507).build());
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
assertThat(doc.getThumbnailKey()).isNull();
verify(documentRepository, never()).save(any());
}
@Test
void generate_returnsFailed_whenSourceStreamThrows() throws IOException {
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
when(fileService.downloadFileStream(anyString()))
.thenThrow(new IOException("network blip"));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any());
}
@Test
void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException {
// Image uploads are always a single page from the user's perspective.
Document doc = makeDoc("image/png", "documents/scan.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getPageCount()).isEqualTo(1);
}
@Test
void generate_persistsPageCount_fromPdfDocument() throws IOException {
Document doc = makeDoc("application/pdf", "documents/multi.pdf");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePdf(3)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getPageCount()).isEqualTo(3);
}
@Test
void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException {
// 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT.
Document doc = makeDoc("image/png", "documents/portrait.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
}
@Test
void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException {
// 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE.
Document doc = makeDoc("image/jpeg", "documents/wide.jpg");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
}
@Test
void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException {
// 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often
// come in at near-square and we want them to live in the portrait tile).
Document doc = makeDoc("image/png", "documents/square.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(500, 500)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
}
@Test
void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException {
// 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT.
Document doc = makeDoc("image/png", "documents/near_threshold.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
}
@Test
void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException {
// Truncated JPEG header — ImageIO returns null rather than throwing.
// Without the corrupt-image guard this would later NPE inside the aspect /
// dimension computation in scaleToWidth.
Document doc = makeDoc("image/jpeg", "documents/corrupt.jpg");
byte[] truncated = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0};
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(truncated));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any());
}
@Test
void generate_returnsFailed_whenPdfBytesAreCorrupt() throws IOException {
// "PDF" header but no body — PDFBox throws IOException while loading.
Document doc = makeDoc("application/pdf", "documents/corrupt.pdf");
byte[] fakePdf = "%PDF-1.4\n".getBytes();
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(fakePdf));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any());
}
@Test
void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException {
// Covers the "orphan thumbnail" edge case: S3 upload succeeded but the
// entity update blew up. We must still return FAILED so the backfill
// tally is honest, without losing the fact that we already put bytes in S3.
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
when(documentRepository.save(any()))
.thenThrow(new RuntimeException("constraint violation"));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentRepository).save(any());
}
// ─── helpers ──────────────────────────────────────────────────────────────
private Document makeDoc(String contentType, String filePath) {
Document doc = Document.builder()
.id(UUID.randomUUID())
.title("Test Doc")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.contentType(contentType)
.filePath(filePath)
.build();
doc.setCreatedAt(LocalDateTime.now());
doc.setUpdatedAt(LocalDateTime.now());
return doc;
}
private static byte[] createSamplePdf() throws IOException {
return createSamplePdf(1);
}
private static byte[] createSamplePdf(int pageCount) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pageCount; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
content.beginText();
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
content.newLineAtOffset(100, 700);
content.showText("Lieber Hans,");
content.endText();
}
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return bos.toByteArray();
}
}
private static byte[] createSamplePng(int width, int height) throws IOException {
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(Color.LIGHT_GRAY);
g.fillRect(0, 0, width, height);
g.setColor(Color.DARK_GRAY);
g.fillRect(0, 0, width, height / 4);
g.dispose();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(img, "png", bos);
return bos.toByteArray();
}
private static byte[] createSampleJpeg(int width, int height) throws IOException {
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, width, height);
g.dispose();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(img, "jpg", bos);
return bos.toByteArray();
}
private static byte[] readAll(InputStream stream) throws IOException {
try (stream) {
return stream.readAllBytes();
}
}
}

View File

@@ -0,0 +1,59 @@
# ADR-003: Session-Rollup Unified Activity Feed on `/chronik`
## Status
Accepted
## Context
The app had two disconnected ways to see what was happening in the archive:
1. `/notifications` — personal mentions/replies only, delivered via the `notifications` table and a Bell dropdown.
2. Dashboard activity feed — ambient events (uploads, transcription, annotations, comments, mentions) via `/api/dashboard/activity`, which deduplicated using `DISTINCT ON (actor_id, document_id, kind, date_trunc('hour', happened_at))`.
Two separate lists was a poor mental model (personal vs. ambient feel the same to the user), the `/notifications` page wasted horizontal space, the dashboard's "Alle anzeigen" pointed to `/documents` (dead-end), and the hour-trunc dedupe produced ugly splits on natural sessions — saving 20 transcription blocks at 08:58, 08:59, 09:01 yielded two rows.
We needed one page that merges both streams, keeps personal mentions visually loud, and aggregates ambient noise coherently.
## Decision
**One page `/chronik` backed by two endpoints.** The SvelteKit `+page.server.ts` composes data from `/api/dashboard/activity` (for the ambient timeline) and `/api/notifications` (for the "Für dich" box). No new `/api/chronik` orchestrator — the frontend load function is the composition seam.
**Session-style rollup replaces hour-trunc dedupe everywhere.** `AuditLogQueryRepository.findDedupedActivityFeed` is renamed to `findRolledUpActivityFeed` and rewritten using a `LAG()`-based session algorithm:
```
LAG(happened_at) OVER (PARTITION BY actor_id, document_id, kind ORDER BY happened_at)
→ is_new_session = gap > 7200s (or first event in partition, or kind ∈ {COMMENT_ADDED, MENTION_CREATED})
→ SUM(is_new_session) OVER (... ROWS UNBOUNDED PRECEDING) = session_id
→ GROUP BY (actor_id, document_id, kind, session_id) → MIN(happened_at), MAX(...), COUNT(*)
```
Events within 120 min on the same `(actor, document, kind)` become one row with `count` and `happenedAtUntil` fields. `COMMENT_ADDED` and `MENTION_CREATED` always start a new session — these kinds never roll up. No hard cap on total session span (a 4-hour transcription sitting is one row). The hour-trunc dedupe SQL is **deleted**, not kept alongside — one aggregation strategy per query.
**URL is universal German `/chronik` across all locales**, matching the existing convention (`/dokumente`, `/personen`, `/briefwechsel`). Content is translated via Paraglide; the URL is a stable German identifier, not a translatable route.
**DTO extended, not replaced.** `ActivityFeedItemDTO` gains `count: int` (required, `1` for singletons) and `happenedAtUntil: OffsetDateTime?` (null for singletons, end-of-session for rollups). One DTO shape serves both the Chronik timeline and the dashboard side-rail.
**`/notifications` route is deleted outright.** The app is pre-production — no 301 redirect, no zombie page.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| Fixed 2-hour wall-clock buckets (`date_trunc('hour', happened_at / 2)`) | Splits natural sessions at bucket boundaries (e.g. events at 13:58 / 13:59 / 14:01 land in two rollup rows) |
| Keep `DISTINCT ON hour-trunc` alongside new rollup query | Two aggregation strategies = zombie logic; dashboard and Chronik would drift |
| New `/api/chronik` endpoint that merges both streams | Couples two domains (notifications + audit) at the API layer; composition belongs in `+page.server.ts` |
| Localized URL slugs (`/chronik` / `/chronicle` / `/crónica`) | Breaks the project's existing German-URL convention and adds Paraglide routing overhead for zero UX value |
| Per-locale rollup in the SQL (e.g. align to local-day boundaries) | Timezone-aware SQL is brittle; rollup is a time-gap concept, not a calendar-day concept |
## Consequences
**Easier:**
- One hot path — `/api/dashboard/activity` is backed by a single partial covering index (`V49__add_audit_log_rollup_index.sql`) that matches the rollup query's WHERE clause exactly.
- Dashboard side-rail gets rollup for free — 20 block-saves appear as one "Papa transkribierte 20 Blöcke" row with a time range, not 20 dedup'd hour buckets.
- Component reuse — `ChronikRow.svelte` renders both singleton and rollup variants via a `$derived` discriminator; `DashboardActivityFeed.svelte` consumes the same DTO shape.
**Harder:**
- The session SQL is ~15 lines longer than `DISTINCT ON`. That's the price for not splitting natural sessions at fixed boundaries — worth it on day one.
- Historical `/api/dashboard/activity` consumers now see `count` and `happenedAtUntil`. No breaking change — `count` defaults to `1`, `happenedAtUntil` is nullable — but pre-existing tests needed updating.
- Rollup is load-bearing for the UX — if the index is missing or the query regresses, the page either runs slow or returns duplicate rows. Covered by the rolledUp integration tests and the partial covering index; worth a follow-up Grafana panel on `/api/dashboard/activity` p95 latency.

View File

@@ -0,0 +1,49 @@
# ADR-004: In-Process PDFBox Thumbnails (not ocr-service)
## Status
Accepted
## Context
The archive lists documents as text-only rows everywhere (home search, person detail, conversation timeline, Chronik). For a fundamentally visual archive — letters, scans, handwritten pages — this is a real discoverability problem. Issue #307 introduces a small JPEG thumbnail for every document.
A viable alternative to rendering in Spring Boot is delegating to the existing `ocr-service` (Python), which already has PyMuPDF/PIL available and is the project's designated place for PDF pixel work. The comparison is not obvious: either place works.
## Decision
Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (already a dependency for training-data export). A dedicated `thumbnailExecutor` pool isolates the work from the shared task pool used by OCR.
- PDF first page rendered via `PDFRenderer.renderImageWithDPI(0, 100, ImageType.RGB)`, scaled to 240 px width (bilinear) and encoded as JPEG quality 85.
- Non-PDF image types (JPEG, PNG, TIFF) decoded via `javax.imageio` — TIFF requires the `twelvemonkeys-imageio-tiff` plugin on the classpath.
- Upload paths fire-and-forget via `ThumbnailAsyncRunner.dispatchAfterCommit(docId)`; a `ThumbnailBackfillService` covers anything the async task missed or that pre-dates this feature.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| Delegate to `ocr-service` (PyMuPDF) | Adds a network hop and a failure mode to every document upload. `ocr-service` is not guaranteed healthy at upload time (model-loading start period is 60 s). PDFBox is already a backend dependency — delegating is a net complexity increase. |
| Render on the frontend with `pdfjs-dist` at display time | Would work for PDFs but not for scans / images; list pages would need to render dozens of PDFs on first paint; no server-side caching. |
| Thumbor / imaginary / a dedicated thumbnail service | Overkill for a single-operator household tool; new container to operate and secure. |
## Consequences
**Easier:**
- Zero new infrastructure. `thumbnails/` is a prefix in the existing MinIO bucket — production migration to Hetzner Object Storage works identically.
- Backfill is a plain sequential loop; no inter-service retry semantics.
- Integration test runs against real MinIO without needing `ocr-service` to be healthy.
**Harder:**
- PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload).
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
### Operational caveats (intentional)
**Backfill state is in-memory and single-node.** `ThumbnailBackfillService.currentStatus` is a volatile reference updated on the thumbnail executor thread. Restarting the backend mid-run loses progress and the next `runBackfillAsync()` starts over. This mirrors `MassImportService.ImportStatus` and is acceptable because the household archive runs as a single Spring Boot process, backfill is a rare one-shot admin action, and re-running the backfill is idempotent (`findByFilePathIsNotNullAndThumbnailKeyIsNull()` naturally skips completed documents).
**`ThumbnailService` and `ThumbnailBackfillService` inject `DocumentRepository` directly.** This is a deliberate exception to the project's "services never reach into another domain's repository" rule. Treating thumbnails as a cross-cutting aspect of `Document` rather than a sub-domain avoids a circular dependency (`DocumentService``ThumbnailAsyncRunner``DocumentService` would close the loop). If thumbnail state grows beyond two columns into its own domain model, extract a proper `ThumbnailRepository` at that point — not before.
## Future Direction
- If a second image-processing job (OCR region crops, sharing previews) arrives, revisit moving all image work to `ocr-service` so the two share a single PyMuPDF instance.
- If thumbnails ever need to be generated at multiple sizes, switch the key pattern from `thumbnails/{docId}.jpg` to `thumbnails/{docId}/{width}.jpg` — the endpoint and cache-bust URL are already structured to accommodate that.

View File

@@ -0,0 +1,52 @@
# ADR-005: thumbnailAspect + pageCount alongside the thumbnail
## Status
Accepted
## Context
Issue #305 rebalances the /briefwechsel correspondence list into PDF-thumbnail rows. Two pieces of metadata are needed at row-render time:
- **Aspect ratio** — postcards are landscape (7:5), letters are portrait (5:7). Forcing landscape scans into a portrait tile crops away the signature; forcing portrait scans into a landscape tile wastes horizontal real estate.
- **Page count** — multi-page letters should show a "N" badge on their thumbnail so the reader can tell a single-page note from a seven-page letter without clicking in.
Both values are cheap to derive at the point the thumbnail is generated (the source image is already decoded; the PDF is already loaded) and impossible to derive cheaply later (requires re-reading the S3 object).
## Decision
Persist both values as columns on `documents` and populate them inside `ThumbnailService.generate()` — the same code path that writes the JPEG to S3 and stamps `thumbnail_generated_at`.
- `thumbnail_aspect VARCHAR(16)` mapped to a Java enum `ThumbnailAspect` with two values: `PORTRAIT`, `LANDSCAPE`.
- `page_count INTEGER``PDDocument.getNumberOfPages()` for PDFs, `1` for image uploads.
- Aspect threshold is `source.width / source.height > 1.1``LANDSCAPE`; everything else (including near-square A4 scans at ratio ≈ 1.0) stays `PORTRAIT`. The 1.1 margin keeps borderline scans from flipping across the threshold on a rounding error.
- Both columns are nullable and remain `null` for historical documents until the existing `/api/admin/generate-thumbnails` backfill rerun populates them.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| Derive aspect client-side after image load | First-paint would have all tiles in portrait, then reshuffle into landscape when the JPEG decodes — a visible jank on slow networks. The backend already has the dimensions; client-side recomputation is a waste. |
| Store full `width` / `height` columns | Not needed anywhere — consumers want the categorical answer. If a future feature needs exact dimensions, they can be added later without migrating existing rows. |
| A separate `thumbnail_metadata` table | Two scalar nullable columns aren't worth a join. See ADR-004 — thumbnails are modeled as a cross-cutting aspect of `Document`, not a sub-domain. |
| Derive page count from the existing PDF at render time on the frontend | Duplicates work already done on the backend and requires a separate byte-range fetch of the PDF header. Frontend already gets `pageCount` "for free" via the Document response. |
## Consequences
**Easier:**
- `ConversationThumbnail.svelte` picks the tile dimensions from `thumbnailAspect` directly — no async measurement, no layout shift.
- `ThumbnailRow` reads `pageCount` synchronously for the badge. Multi-page letters are distinguishable at first paint.
- Backfill runs the same migration path for every old document — re-executing generates the aspect + pageCount columns along with the JPEG, so operators don't have a second admin button to click.
**Harder:**
- Both columns are `null` for every document until the backfill runs on a given instance. Frontend components guard with `?? 'PORTRAIT'` / `?? 1` so the UI stays sensible during the rollout window. The backfill is idempotent and cheap (reuses existing S3 object), so re-running it is the simplest recovery path.
- The aspect threshold is a single constant in Java. A future need to tune per-type (e.g. postcards vs photos) means a code change, not a configuration change — acceptable for a single-operator archive.
### Ordering inside `ThumbnailService.generate()`
Aspect computation happens AFTER the JPEG upload succeeds but BEFORE the entity save — if the save throws, the columns rewind with it. Page count is captured while the `PDDocument` is still open; the `SourcePreview` record carries both the rendered first-page image and the page count back to the top of the pipeline so the PDF isn't reopened later.
## Future Direction
- If a postcard-specific "photo" chip is ever reintroduced, reuse `thumbnailAspect === 'LANDSCAPE' && pageCount === 1` rather than adding a new `kind` column.
- If multi-size thumbnails are introduced (per ADR-004's future note), the aspect + pageCount are per-document and do not need to be duplicated per size.

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spec 1 — Rich Rows · Briefwechsel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="_shared.css">
<style>
/* Spec 1 specific */
.rlist{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
.row{display:grid;grid-template-columns:20px minmax(0,1fr) auto;column-gap:12px;align-items:stretch;padding:14px 18px;border-bottom:1px solid var(--line-2);border-left:3px solid transparent;cursor:pointer;transition:background .1s}
.row:hover{background:var(--muted)}
.row:last-child{border-bottom:0}
.row.out{border-left-color:var(--primary)}
.row.in{border-left-color:var(--accent)}
.row-arrow{align-self:center;font-size:14px;opacity:.55;display:flex;justify-content:center}
.row-body{min-width:0;display:flex;flex-direction:column;gap:4px}
.row-title{font-family:'Merriweather',serif;font-size:15px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row-summary{font-size:12.5px;color:#555;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90%}
.row-meta{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11.5px;color:var(--ink-3);align-items:center}
.row-meta .sep{color:#bbb}
.row-meta .ico{width:12px;height:12px;opacity:.55;display:inline-flex;align-items:center;justify-content:center}
.row-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
.row-right{display:flex;flex-direction:column;align-items:flex-end;justify-content:center;gap:4px;min-width:130px;padding-left:16px;border-left:1px dashed var(--line)}
.row-archive{font-size:10px;font-weight:800;letter-spacing:.8px;color:#888;text-transform:uppercase;background:#F4F1EA;padding:3px 8px;border-radius:2px}
.row-archive small{display:block;font-weight:600;color:#aaa;margin-top:1px;text-transform:none;letter-spacing:0;font-size:9.5px}
@media (max-width: 900px){ .row-right{display:none} }
</style>
</head>
<body>
<div class="spec-meta">
<div class="spec-meta-inner">
<div>
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
</div>
<div class="spec-meta-right">
<div><strong>Concept</strong>Rich Rows</div>
<div><strong>Spec</strong>1 / 5</div>
<div><strong>Effort</strong>Small — no new backend data</div>
</div>
</div>
</div>
<nav class="spec-nav">
<div class="spec-nav-inner">
<span class="lbl">Specs</span>
<a href="index.html">Overview</a>
<a class="on" href="01-rich-rows.html">1 · Rich Rows</a>
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
<a href="03-master-detail.html">3 · Master-Detail Split</a>
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
</div>
</nav>
<div class="page-wrap">
<!-- Real Familienarchiv chrome -->
<div class="hdr">
<div class="hdr-logo">FAMILIENARCHIV</div>
<div class="hdr-nav">
<a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a>
</div>
<div class="hdr-right">
<div class="hdr-upload">⬆ UPLOAD</div>
<span>DE · EN · ES</span>
<div class="hdr-avatar">MR</div>
</div>
</div>
<div class="page">
<div class="concept-intro">
<h2>Concept 1 · Rich Rows — pack more metadata into each row</h2>
No visuals, no structural change. Each row grows from a single line to a layered block: title (serif), summary (italic), meta row with icons, tag chips, and a right-hand column with archive box, script type and status.
<div><span class="gain">✚ Zero backend changes</span><span class="gain">✚ Still one scrollable list</span><span class="cost"> Heavier rows; 10-row view becomes ~67 rows</span><span class="cost"> Empty-looking when a doc has no summary/tags</span></div>
</div>
<!-- Filter card (same as production) -->
<div class="card">
<div class="filter-row">
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
</div>
<div class="filter-actions">
<div class="btn">Newest ↓</div>
<div class="btn">▾ Filter</div>
<div class="count"><b>851</b> Briefe</div>
</div>
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
</div>
<div class="rlist">
<div class="year-divider"><span class="y">1940</span><span class="n">1 Brief</span></div>
<div class="row in">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">Demo leserlicher Brief</div>
<div class="row-summary">„letzte Lebenstage von W. Dörpfeld in Griechenland"</div>
<div class="row-meta"><span>31. Mai 1940</span><span class="sep">·</span><span>📍 Belgard</span><span class="sep">·</span><span>von <b>Gertrud von Rofden</b></span></div>
<div class="row-tags"><span class="tag">Dörpfeld</span><span class="tag">Griechenland</span><span class="tag muted">privat</span></div>
</div>
<div class="row-right">
<div class="row-archive">Kasten VII · Mappe 5</div>
</div>
</div>
<div class="year-divider"><span class="y">1923</span><span class="n">5 Briefe</span></div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0397 2. September 1923 B.Lichterfelde</div>
<div class="row-summary">„von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte"</div>
<div class="row-meta"><span>2. September 1923</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
<div class="row-tags"><span class="tag">Verlag</span><span class="tag">Familie</span></div>
</div>
<div class="row-right">
<div class="row-archive">Kasten VI · Mappe 7</div>
</div>
</div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0396 2. September 1923 B.Lichterfelde</div>
<div class="row-summary"></div>
<div class="row-meta"><span>2. September 1923</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
</div>
<div class="row-right">
<div class="row-archive">Kasten VI · Mappe 7</div>
</div>
</div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0524 31. Juli 1923 Berlin</div>
<div class="row-summary">„Glückwunsch zum 60. Geburtstag, Bericht über den Verlag"</div>
<div class="row-meta"><span>31. Juli 1923</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
<div class="row-tags"><span class="tag">Geburtstag</span><span class="tag">Verlag</span></div>
</div>
<div class="row-right">
<div class="row-archive">Kasten VI · Mappe 7</div>
</div>
</div>
<div class="year-divider"><span class="y">1922</span><span class="n">37 Briefe</span></div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0521 24. Dezember 1922 Berlin</div>
<div class="row-summary">„Weihnachtsbrief, Erinnerungen an das Jahr und Bitte um ein Bild der Kinder"</div>
<div class="row-meta"><span>24. Dezember 1922</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
<div class="row-tags"><span class="tag">Weihnachten</span><span class="tag">Familie</span><span class="tag muted">persönlich</span></div>
</div>
<div class="row-right">
<div class="row-archive">Kasten V · Mappe 3</div>
</div>
</div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0392 23. November 1921 Bad Kissingen</div>
<div class="row-summary">„Kurbericht, Gesundheitsupdate, Grüße an die Familie Cram"</div>
<div class="row-meta"><span>23. November 1921</span><span class="sep">·</span><span>📍 Bad Kissingen</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
<div class="row-tags"><span class="tag">Kuraufenthalt</span></div>
</div>
<div class="row-right">
<div class="row-archive">Kasten V · Mappe 1</div>
</div>
</div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0501 13. Dezember 1920 Berlin</div>
<div class="row-summary"></div>
<div class="row-meta"><span>13. Dezember 1920</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
</div>
<div class="row-right">
<div class="row-archive">Kasten IV · Mappe 8</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spec 2 — Thumbnail Rows · Briefwechsel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="_shared.css">
<style>
/* Spec 2 v2 — bigger thumbnails, postcard support, bilateral distribution bar */
.rlist{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
.row{display:grid;grid-template-columns:104px 1fr auto;column-gap:20px;align-items:center;padding:14px 20px;border-bottom:1px solid var(--line-2);border-left:3px solid transparent;cursor:pointer;transition:background .12s,box-shadow .12s}
.row:hover{background:var(--muted)}
.row:hover .row-thumb .thumb{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.1),inset 0 0 0 1px #fff}
.row:last-child{border-bottom:0}
.row.out{border-left-color:var(--primary)}
.row.in{border-left-color:var(--accent)}
/* Thumbnail wrapper — fixed 104×104 cell, thumb centered */
.row-thumb{width:104px;height:120px;display:flex;align-items:center;justify-content:center;position:relative}
.thumb{transition:transform .12s,box-shadow .12s;box-shadow:0 1px 3px rgba(0,0,0,.08),inset 0 0 0 1px #fff}
.thumb.portrait{width:82px;height:106px}
.thumb.landscape{width:104px;height:72px}
.thumb.postcard{width:104px;height:66px}
.thumb-badge{position:absolute;top:2px;right:0;background:var(--brand-navy);color:#fff;font-size:9px;font-weight:800;padding:2px 6px;border-radius:10px;box-shadow:0 0 0 2px #fff}
/* Subtle paper variations for natural feel */
.thumb.paper-1{background:linear-gradient(180deg,#fdfcf7 0%,#f4efdf 100%)}
.thumb.paper-2{background:linear-gradient(180deg,#fefdf8 0%,#eee8d3 100%)}
.thumb.paper-3{background:linear-gradient(180deg,#fbf8ed 0%,#efe7cb 100%)}
.thumb.paper-4{background:linear-gradient(180deg,#fdfcf5 0%,#f0e9d5 100%)}
/* Kurrent-style handwriting — denser, angled */
.thumb.kurrent .thumb-lines{padding:14% 9%;gap:3.5px}
.thumb.kurrent .thumb-lines i{height:1.3px;background:rgba(24,40,70,.45);transform:rotate(-.5deg)}
.thumb.kurrent .thumb-lines i:nth-child(3n){width:65%}
.thumb.kurrent .thumb-lines i:nth-child(4n){width:92%}
.thumb.kurrent .thumb-lines i:nth-child(5n){width:48%;transform:rotate(.4deg)}
/* Typewriter — regular, crisp */
.thumb.typed .thumb-lines{padding:16% 12%;gap:2.5px}
.thumb.typed .thumb-lines i{height:1px;background:rgba(40,40,40,.45)}
.thumb.typed .thumb-lines i:nth-child(odd){width:93%}
.thumb.typed .thumb-lines i:nth-child(even){width:88%}
.thumb.typed .thumb-lines i:nth-child(7n){width:45%}
/* Postcard — stamp corner + postmark + short address lines */
.thumb.postcard .thumb-lines{padding:10% 10% 14% 10%;gap:4px}
.thumb.postcard .thumb-lines i{height:1.1px;background:rgba(24,40,70,.45)}
.thumb.postcard .thumb-lines i:nth-child(1){width:60%}
.thumb.postcard .thumb-lines i:nth-child(2){width:45%}
.thumb.postcard .thumb-lines i:nth-child(3){width:70%}
.thumb.postcard .thumb-lines i:nth-child(4){width:40%}
.thumb.postcard::after{content:'';position:absolute;top:6px;right:6px;width:16px;height:18px;background:linear-gradient(135deg,#b6c9d3,#8ba9b6);border:1px dashed rgba(0,0,0,.15);box-shadow:0 0 0 1px #fff}
.thumb.postcard::before{content:'';position:absolute;top:10px;right:26px;width:14px;height:14px;border:1.5px solid rgba(150,30,30,.4);border-radius:50%;background:radial-gradient(circle,rgba(150,30,30,.1) 40%,transparent 60%)}
/* Letter heading (typed with date/address at top) */
.thumb.typed::before{content:'';position:absolute;top:10%;left:12%;right:12%;height:2px;background:transparent;border-bottom:1.5px solid rgba(40,40,40,.35)}
.row-body{min-width:0;display:flex;flex-direction:column;gap:4px}
.row-title{font-family:'Merriweather',serif;font-size:16px;font-weight:700;color:var(--ink);line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row-summary{font-family:'Merriweather',serif;font-size:14px;color:#444;line-height:1.55;font-style:italic;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.row-summary::before{content:'„';color:var(--brand-mint);font-size:22px;font-weight:700;line-height:0;position:relative;top:6px;margin-right:2px}
.row-summary::after{content:'”';color:var(--brand-mint);font-size:22px;font-weight:700;line-height:0;position:relative;top:6px;margin-left:2px}
.row-meta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--ink-3);align-items:center;margin-top:2px}
.row-meta .sep{color:#ccc}
.row-meta .dir-ch{color:var(--primary);font-weight:800;font-size:13px}
.row-meta .dir-ch.in{color:var(--accent)}
.row-meta .kind-chip{display:inline-flex;align-items:center;gap:3px;background:#F4F1EA;color:#666;font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;letter-spacing:.3px;text-transform:uppercase}
.row-tags{display:flex;gap:4px;flex-wrap:wrap}
.row-right{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
.row-date{font-family:'Merriweather',serif;font-size:14px;color:#444;white-space:nowrap;font-weight:700}
.row-date-rel{font-size:10.5px;color:#aaa;font-weight:600;letter-spacing:.3px}
/* Bilateral distribution bar — lifted from production ConversationTimeline */
.distbar{display:flex;flex-direction:column;gap:6px;background:var(--muted);border:1px solid var(--line);border-bottom:0;padding:12px 20px}
.distbar-labels{display:flex;justify-content:space-between;font-size:13px;font-weight:700}
.distbar-labels .out{color:var(--primary);display:inline-flex;align-items:center;gap:6px}
.distbar-labels .in{color:var(--accent);display:inline-flex;align-items:center;gap:6px}
.distbar-labels .cnt{font-variant-numeric:tabular-nums}
.distbar-bar{height:6px;display:flex;border-radius:3px;overflow:hidden;background:var(--line)}
.distbar-bar .out{background:var(--primary)}
.distbar-bar .in{background:var(--accent)}
.distbar + .rlist{border-radius:0 0 2px 2px}
/* Section headings within the spec */
.example-h{font-family:'Merriweather',serif;font-size:18px;color:var(--brand-navy);margin:36px 0 10px;padding-top:24px;border-top:1px dashed var(--line);font-weight:700;display:flex;align-items:baseline;gap:10px}
.example-h .lbl{font-family:'Montserrat',sans-serif;font-size:10px;font-weight:800;color:#888;letter-spacing:1px;text-transform:uppercase}
.example-h:first-of-type{border-top:0;padding-top:0;margin-top:20px}
.example-sub{font-size:12px;color:#777;margin-bottom:14px;line-height:1.55}
/* Swap-buttons and filter chrome for bilateral filter card */
.swap-inline{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid #C8C4BE;border-radius:50%;background:#F0EDE8;font-size:13px;color:var(--brand-navy);margin:0 -12px;position:relative;z-index:1}
@media (max-width: 760px){
.row{grid-template-columns:82px 1fr;column-gap:14px}
.row-right{grid-column:2;align-items:flex-start;margin-top:4px}
.thumb.portrait{width:72px;height:94px}
.thumb.landscape, .thumb.postcard{width:82px;height:58px}
}
</style>
</head>
<body>
<div class="spec-meta">
<div class="spec-meta-inner">
<div>
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
</div>
<div class="spec-meta-right">
<div><strong>Concept</strong>Thumbnail Rows <span style="color:var(--brand-mint);margin-left:6px">v2</span></div>
<div><strong>Spec</strong>2 / 5</div>
<div><strong>Effort</strong>Medium — needs PDF thumbnail service</div>
</div>
</div>
</div>
<nav class="spec-nav">
<div class="spec-nav-inner">
<span class="lbl">Specs</span>
<a href="index.html">Overview</a>
<a href="01-rich-rows.html">1 · Rich Rows</a>
<a class="on" href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
<a href="03-master-detail.html">3 · Master-Detail Split</a>
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
</div>
</nav>
<div class="page-wrap">
<div class="hdr">
<div class="hdr-logo">FAMILIENARCHIV</div>
<div class="hdr-nav"><a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a></div>
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
</div>
<div class="page">
<div class="concept-intro">
<h2>Concept 2 · Thumbnail Rows — discovery through visual + summary</h2>
/briefwechsel is for fun discovery, not dense scanning. The row gets a bigger first-page thumbnail (portrait for letters, landscape for postcards); the <b>summary</b> reads like a quote next to it; the right column stays calm — just the date. Rows without a summary remain clean and uncrowded.
<div><span class="gain">✚ Visual recognition — letters and postcards look like what they are</span><span class="gain">✚ Summary reads as a quote, invites opening the letter</span><span class="gain">✚ Distribution bar gives the bilateral pair its own identity</span><span class="cost"> Depends on the PDF-thumbnail service (open issue)</span></div>
</div>
<!-- ───────── Example 1 · single person ───────── -->
<div class="example-h">Beispiel 1 <span class="lbl">alle Briefe von Walter de Gruyter · 851</span></div>
<div class="example-sub">Single-sender case: sender is filled, correspondent is open. Direction arrows tell sent vs received.</div>
<div class="card">
<div class="filter-row">
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
</div>
<div class="filter-actions">
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
<div class="count"><b>851</b> Briefe</div>
</div>
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
</div>
<div class="rlist">
<div class="year-divider"><span class="y">1940</span><span class="n">1 Brief</span></div>
<div class="row in">
<div class="row-thumb">
<div class="thumb portrait typed paper-1">
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
</div>
<div class="row-body">
<div class="row-title">Demo leserlicher Brief</div>
<div class="row-summary">letzte Lebenstage von W. Dörpfeld in Griechenland — ausführlicher Bericht aus Belgard mit persönlichen Anmerkungen</div>
<div class="row-meta"><span class="dir-ch in">← eingehend</span><span>Gertrud von Rofden</span><span class="sep">·</span><span>📍 Belgard</span><span class="sep">·</span><span class="row-tags"><span class="tag">Dörpfeld</span><span class="tag">Griechenland</span></span></div>
</div>
<div class="row-right">
<div class="row-date">31. Mai 1940</div>
<div class="row-date-rel">vor 85 Jahren</div>
</div>
</div>
<div class="year-divider"><span class="y">1923</span><span class="n">5 Briefe</span></div>
<div class="row out">
<div class="row-thumb">
<div class="thumb portrait kurrent paper-2">
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
</div>
<div class="row-body">
<div class="row-title">W-0397 2. September 1923 B.Lichterfelde</div>
<div class="row-summary">von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte — Notiz auf der Rückseite mit Korrekturen</div>
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span><span class="tag">Familie</span></span></div>
</div>
<div class="row-right">
<div class="row-date">2. September 1923</div>
<div class="row-date-rel">vor 102 Jahren</div>
</div>
</div>
<!-- Postcard example -->
<div class="row out">
<div class="row-thumb">
<div class="thumb postcard kurrent paper-4">
<div class="thumb-lines"><i></i><i></i><i></i><i></i></div>
</div>
</div>
<div class="row-body">
<div class="row-title">Ansichtskarte 2. September 1923 B.Lichterfelde</div>
<div class="row-summary">kurze Grüße aus B.Lichterfelde, Hinweis auf den kommenden Besuch</div>
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="kind-chip">✉ Postkarte</span></div>
</div>
<div class="row-right">
<div class="row-date">2. September 1923</div>
<div class="row-date-rel">vor 102 Jahren</div>
</div>
</div>
<!-- Multi-page letter -->
<div class="row out">
<div class="row-thumb">
<div class="thumb portrait kurrent paper-3">
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
<span class="thumb-badge">4 S.</span>
</div>
<div class="row-body">
<div class="row-title">W-0524 31. Juli 1923 Berlin</div>
<div class="row-summary">Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den anstehenden Umzug nach B.Lichterfelde im kommenden Herbst</div>
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Walter Dieckmann</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span class="row-tags"><span class="tag">Geburtstag</span><span class="tag">Verlag</span></span></div>
</div>
<div class="row-right">
<div class="row-date">31. Juli 1923</div>
<div class="row-date-rel">vor 102 Jahren</div>
</div>
</div>
<!-- Without summary — still clean -->
<div class="row out">
<div class="row-thumb">
<div class="thumb portrait kurrent paper-1">
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
</div>
<div class="row-body">
<div class="row-title">W-0396 2. September 1923 B.Lichterfelde</div>
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span></div>
</div>
<div class="row-right">
<div class="row-date">2. September 1923</div>
<div class="row-date-rel">vor 102 Jahren</div>
</div>
</div>
<div class="year-divider"><span class="y">1922</span><span class="n">37 Briefe</span></div>
<div class="row out">
<div class="row-thumb">
<div class="thumb portrait kurrent paper-2">
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
</div>
<div class="row-body">
<div class="row-title">W-0521 24. Dezember 1922 Berlin</div>
<div class="row-summary">Weihnachtsbrief, Erinnerungen an das Jahr und Bitte um ein Bild der Kinder zum Christfest</div>
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Walter Dieckmann</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span class="row-tags"><span class="tag">Weihnachten</span><span class="tag">Familie</span></span></div>
</div>
<div class="row-right">
<div class="row-date">24. Dezember 1922</div>
<div class="row-date-rel">vor 103 Jahren</div>
</div>
</div>
</div>
<!-- ───────── Example 2 · bilateral ───────── -->
<div class="example-h">Beispiel 2 <span class="lbl">Briefwechsel Walter ↔ Herbert · 143</span></div>
<div class="example-sub">Bilateral case: both filters are set. The distribution bar above the list shows how the correspondence is split — instantly visible who wrote more.</div>
<div class="card">
<div class="filter-row">
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
<div><div class="fl">Korrespondent</div><div class="fi">Herbert Cram</div></div>
</div>
<div class="filter-actions">
<div class="btn">⇄ Tauschen</div>
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
<div class="count"><b>143</b> Briefe im Zeitraum</div>
</div>
</div>
<div class="distbar" role="img" aria-label="Briefverteilung: 87 von Walter de Gruyter, 56 von Herbert Cram">
<div class="distbar-labels">
<span class="out"><span class="cnt">87</span> von Walter de Gruyter →</span>
<span class="in"><span class="cnt">56</span> von Herbert Cram</span>
</div>
<div class="distbar-bar">
<span class="out" style="width:60.8%"></span>
<span class="in" style="width:39.2%"></span>
</div>
</div>
<div class="rlist" style="border-radius:0 0 2px 2px">
<div class="year-divider"><span class="y">1923</span><span class="n">12 Briefe</span></div>
<div class="row out">
<div class="row-thumb">
<div class="thumb portrait kurrent paper-2">
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
</div>
<div class="row-body">
<div class="row-title">W-0397 2. September 1923 B.Lichterfelde</div>
<div class="row-summary">von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte</div>
<div class="row-meta"><span class="dir-ch"></span><span>Walter an Herbert</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span></span></div>
</div>
<div class="row-right">
<div class="row-date">2. September 1923</div>
<div class="row-date-rel">vor 102 Jahren</div>
</div>
</div>
<div class="row in">
<div class="row-thumb">
<div class="thumb portrait kurrent paper-3">
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
</div>
<div class="row-body">
<div class="row-title">H-0213 29. August 1923 Leipzig</div>
<div class="row-summary">Antwort auf Walters Anfrage zur Herbstauslieferung, Bitte um Rückmeldung bis Monatsende</div>
<div class="row-meta"><span class="dir-ch in"></span><span>Herbert an Walter</span><span class="sep">·</span><span>📍 Leipzig</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span></span></div>
</div>
<div class="row-right">
<div class="row-date">29. August 1923</div>
<div class="row-date-rel">vor 102 Jahren</div>
</div>
</div>
<div class="row in">
<div class="row-thumb">
<div class="thumb postcard kurrent paper-4">
<div class="thumb-lines"><i></i><i></i><i></i><i></i></div>
</div>
</div>
<div class="row-body">
<div class="row-title">Ansichtskarte 20. August 1923 Thüringer Wald</div>
<div class="row-summary">Urlaubsgruß, kurze Notiz über Wetter und geplante Rückkehr</div>
<div class="row-meta"><span class="dir-ch in"></span><span>Herbert an Walter</span><span class="sep">·</span><span>📍 Thüringer Wald</span><span class="sep">·</span><span class="kind-chip">✉ Postkarte</span></div>
</div>
<div class="row-right">
<div class="row-date">20. August 1923</div>
<div class="row-date-rel">vor 102 Jahren</div>
</div>
</div>
<div class="row out">
<div class="row-thumb">
<div class="thumb portrait kurrent paper-1">
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
<span class="thumb-badge">3 S.</span>
</div>
<div class="row-body">
<div class="row-title">W-0392 23. November 1921 Bad Kissingen</div>
<div class="row-summary">Kurbericht aus Bad Kissingen, Gesundheitsupdate nach der ersten Woche, Grüße an die Familie Cram</div>
<div class="row-meta"><span class="dir-ch"></span><span>Walter an Herbert</span><span class="sep">·</span><span>📍 Bad Kissingen</span><span class="sep">·</span><span class="row-tags"><span class="tag">Kuraufenthalt</span></span></div>
</div>
<div class="row-right">
<div class="row-date">23. November 1921</div>
<div class="row-date-rel">vor 104 Jahren</div>
</div>
</div>
</div>
<!-- Notes footer -->
<div style="margin-top:32px;padding:16px 20px;background:#fff;border-left:4px solid var(--brand-navy);font-size:13px;color:#333;line-height:1.7">
<b style="color:var(--brand-navy)">Details:</b>
<ul style="margin:8px 0 0 20px;padding:0">
<li><b>Thumbnail</b> — 82×106 for portrait, 104×72 for landscape/postcards. Postcards also get a stamp + postmark corner. Kurrent handwriting rendered with slight line skew; typewriter rendered with clean parallel lines. Multi-page letters get a "<code>4 S.</code>" badge.</li>
<li><b>Summary</b> — shown in serif italic with colored quote marks. Reads like a quote from the letter. If empty, the row simply omits the line — no apologetic placeholder.</li>
<li><b>Right column</b> — date only, in serif. We dropped archive box (only meaningful for one family archive) and any lookup metadata. The right column stays calm on purpose.</li>
<li><b>Distribution bar</b> — appears only in bilateral mode (both sender and receiver set). Pattern lifted from the existing <code>ConversationTimeline</code> so it's familiar.</li>
<li><b>Mobile</b> — thumbnail shrinks (72×94 portrait / 82×58 landscape) and the right column wraps under the body.</li>
</ul>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spec 3 — Master-Detail · Briefwechsel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="_shared.css">
<style>
/* Spec 3 specific */
.split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);gap:24px;align-items:start}
@media (max-width: 1100px){ .split{grid-template-columns:1fr} .preview-col{position:static !important} }
/* List (compact) */
.rlist{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
.row{display:grid;grid-template-columns:16px 1fr auto;column-gap:10px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line-2);border-left:3px solid transparent;cursor:pointer}
.row:hover{background:var(--muted)}
.row.out{border-left-color:var(--primary)}
.row.in{border-left-color:var(--accent)}
.row.sel{background:#e7f4f3;border-left-color:var(--brand-mint);box-shadow:inset 2px 0 0 var(--accent)}
.row-arrow{font-size:13px;opacity:.55}
.row-body{min-width:0}
.row-title{font-family:'Merriweather',serif;font-size:13.5px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:2px}
.row-sub{font-size:11px;color:var(--ink-3);display:flex;gap:6px;align-items:center}
.row-sub .sep{color:#ccc}
.row-right{font-size:11px;color:var(--ink-3);display:flex;align-items:center;gap:6px}
/* Preview panel */
.preview-col{position:sticky;top:20px}
.preview{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.04)}
.prev-hdr{padding:16px 20px;background:#fafaf5;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
.prev-hdr-left .prev-kind{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--accent);margin-bottom:4px}
.prev-hdr-left h2{font-family:'Merriweather',serif;font-size:18px;color:var(--brand-navy);line-height:1.35}
.prev-hdr .close{font-size:18px;color:#999;cursor:pointer}
.prev-body{display:grid;grid-template-columns:200px 1fr;gap:20px;padding:20px}
.prev-thumb{width:200px;height:260px;flex-shrink:0}
.prev-thumb .thumb{width:100%;height:100%}
.prev-meta{display:flex;flex-direction:column;gap:14px}
.mkv{display:grid;grid-template-columns:92px 1fr;row-gap:6px;column-gap:10px;font-size:12px;color:#444}
.mkv .k{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:#888;padding-top:2px}
.mkv .v{color:var(--ink)}
.mkv .v b{color:var(--brand-navy)}
.prev-summary{background:#fbfaf5;border-left:3px solid var(--brand-mint);padding:10px 14px;font-family:'Merriweather',serif;font-size:13px;color:#333;line-height:1.65;font-style:italic}
.prev-tags{display:flex;flex-wrap:wrap;gap:5px}
.prev-excerpt{padding:0 20px 18px;font-family:'Merriweather',serif;font-size:13px;color:#444;line-height:1.75;border-top:1px dashed var(--line);padding-top:18px}
.prev-excerpt .lbl{font-family:'Montserrat',sans-serif;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:8px;display:block;font-style:normal}
.prev-actions{border-top:1px solid var(--line);padding:12px 20px;background:#fafaf5;display:flex;gap:8px;justify-content:flex-end}
.prev-actions .btn.primary{background:var(--brand-navy);color:#fff;border-color:var(--brand-navy)}
.prev-hint{padding:14px 20px;background:#f7f5f2;color:#777;font-size:11.5px;border-top:1px dashed var(--line);font-style:italic}
</style>
</head>
<body>
<div class="spec-meta">
<div class="spec-meta-inner">
<div>
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
</div>
<div class="spec-meta-right">
<div><strong>Concept</strong>Master-Detail Split</div>
<div><strong>Spec</strong>3 / 5</div>
<div><strong>Effort</strong>Medium — requires selection state</div>
</div>
</div>
</div>
<nav class="spec-nav">
<div class="spec-nav-inner">
<span class="lbl">Specs</span>
<a href="index.html">Overview</a>
<a href="01-rich-rows.html">1 · Rich Rows</a>
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
<a class="on" href="03-master-detail.html">3 · Master-Detail Split</a>
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
</div>
</nav>
<div class="page-wrap">
<div class="hdr">
<div class="hdr-logo">FAMILIENARCHIV</div>
<div class="hdr-nav"><a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a></div>
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
</div>
<div class="page">
<div class="concept-intro">
<h2>Concept 3 · Master-Detail Split — list on left, preview panel on right</h2>
Keep the list compact (it stays scannable). Add a persistent right-hand panel that fills the empty space with a preview of the selected letter — thumbnail, metadata, summary, and a transcription excerpt when available. Click a row to swap its content; "Open" button still navigates to the full document page.
<div><span class="gain">✚ Users browse 10× more letters per session (no back-nav)</span><span class="gain">✚ Right column is always doing work</span><span class="cost"> On narrow viewports collapses to stacked</span><span class="cost"> Mobile pattern differs — needs sheet/drawer</span></div>
</div>
<!-- Filter card -->
<div class="card">
<div class="filter-row">
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
</div>
<div class="filter-actions">
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
<div class="count"><b>851</b> Briefe</div>
</div>
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
</div>
<div class="split">
<!-- Master list -->
<div class="rlist">
<div class="year-divider"><span class="y">1940</span><span class="n">1 Brief</span></div>
<div class="row in">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">Demo leserlicher Brief</div>
<div class="row-sub"><span>31. Mai 1940</span><span class="sep">·</span><span>Belgard</span><span class="sep">·</span><span>Gertrud von Rofden</span></div>
</div>
</div>
<div class="year-divider"><span class="y">1923</span><span class="n">5 Briefe</span></div>
<div class="row out sel">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0397 2. September 1923 B.Lichterfelde</div>
<div class="row-sub"><span>2. Sep 1923</span><span class="sep">·</span><span>B.Lichterfelde</span><span class="sep">·</span><span>Herbert Cram</span></div>
</div>
</div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0396 2. September 1923 B.Lichterfelde</div>
<div class="row-sub"><span>2. Sep 1923</span><span class="sep">·</span><span>B.Lichterfelde</span><span class="sep">·</span><span>Herbert Cram</span></div>
</div>
</div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0524 31. Juli 1923 Berlin</div>
<div class="row-sub"><span>31. Juli 1923</span><span class="sep">·</span><span>Berlin</span><span class="sep">·</span><span>Walter Dieckmann</span></div>
</div>
</div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0523 12. Mai 1923 Berlin</div>
<div class="row-sub"><span>12. Mai 1923</span><span class="sep">·</span><span>Berlin</span><span class="sep">·</span><span>Walter Dieckmann</span></div>
</div>
</div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0522 7. März 1923 Lindau Bodensee</div>
<div class="row-sub"><span>7. März 1923</span><span class="sep">·</span><span>Lindau Bodensee</span><span class="sep">·</span><span>Walter Dieckmann</span></div>
</div>
</div>
<div class="year-divider"><span class="y">1922</span><span class="n">37 Briefe</span></div>
<div class="row out">
<div class="row-arrow"></div>
<div class="row-body">
<div class="row-title">W-0521 24. Dezember 1922 Berlin</div>
<div class="row-sub"><span>24. Dez 1922</span><span class="sep">·</span><span>Berlin</span><span class="sep">·</span><span>Walter Dieckmann</span></div>
</div>
</div>
</div>
<!-- Detail preview -->
<div class="preview-col">
<div class="preview">
<div class="prev-hdr">
<div class="prev-hdr-left">
<div class="prev-kind">→ Ausgehend · an Herbert Cram</div>
<h2>W-0397 2. September 1923 B.Lichterfelde</h2>
</div>
<div class="close"></div>
</div>
<div class="prev-body">
<div class="prev-thumb">
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
</div>
<div class="prev-meta">
<div class="mkv">
<div class="k">Datum</div><div class="v"><b>2. September 1923</b></div>
<div class="k">Ort</div><div class="v">B.Lichterfelde</div>
<div class="k">Absender</div><div class="v"><b>Walter de Gruyter</b></div>
<div class="k">Empfänger</div><div class="v">Herbert Cram</div>
<div class="k">Archiv</div><div class="v">Kasten VI · Mappe 7</div>
</div>
<div class="prev-summary">„von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte — Notiz auf der Rückseite mit Korrekturen zur Publikationsliste."</div>
<div class="prev-tags"><span class="tag">Verlag</span><span class="tag">Familie</span><span class="tag muted">Korrespondenz</span></div>
</div>
</div>
<div class="prev-excerpt">
<span class="lbl">Transkription (Auszug)</span>
„Lieber Herbert, heute erreichte mich Dein Brief vom 29. August, und ich danke Dir herzlich für die ausführliche Schilderung. Die Angelegenheit mit dem Verlag soll am Montag abschließend besprochen werden — ich werde Dir sogleich Bescheid geben. In Belgard waren wir alle wohl, und die Kinder grüßen …"
</div>
<div class="prev-actions">
<div class="btn">◎ In Briefwechsel öffnen</div>
<div class="btn primary">↗ Vollständiges Dokument</div>
</div>
</div>
<div style="margin-top:8px;font-size:11px;color:#888;padding:0 4px">Tipp: Pfeiltasten ↑↓ zum Durchblättern, Enter zum Öffnen.</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spec 4 — Gallery Cards · Briefwechsel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="_shared.css">
<style>
/* Spec 4 specific */
.view-toggle{display:flex;gap:4px;background:#F0EDE5;padding:3px;border-radius:3px;margin-left:8px}
.view-toggle span{padding:5px 10px;font-size:11px;font-weight:700;color:#888;cursor:pointer;border-radius:2px}
.view-toggle span.on{background:#fff;color:var(--brand-navy);box-shadow:0 1px 2px rgba(0,0,0,.08)}
.year-band{font-family:'Merriweather',serif;font-size:28px;font-weight:900;color:var(--brand-navy);letter-spacing:-.5px;margin:24px 0 10px 2px;display:flex;align-items:baseline;gap:12px}
.year-band .n{font-family:'Montserrat',sans-serif;font-size:12px;font-weight:700;color:#888;letter-spacing:0}
.grid{display:grid;grid-template-columns:repeat(4, 1fr);gap:18px}
@media (max-width: 1280px){ .grid{grid-template-columns:repeat(3, 1fr)} }
@media (max-width: 900px){ .grid{grid-template-columns:repeat(2, 1fr)} }
.card-doc{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden;cursor:pointer;transition:transform .12s,box-shadow .12s;display:flex;flex-direction:column}
.card-doc:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.08)}
.card-doc .thumb-wrap{position:relative;aspect-ratio:3/4;overflow:hidden;background:#f5f2ea;border-bottom:1px solid var(--line)}
.card-doc .thumb{width:100%;height:100%}
.card-doc .dir-badge{position:absolute;top:8px;left:8px;background:rgba(0,40,80,.9);color:#fff;font-size:10px;font-weight:800;padding:3px 8px;border-radius:10px;display:flex;align-items:center;gap:3px;letter-spacing:.3px}
.card-doc .dir-badge.in{background:rgba(47,158,149,.9)}
.card-doc .body{padding:12px 14px 14px;display:flex;flex-direction:column;gap:4px;flex:1}
.card-doc .body h3{font-family:'Merriweather',serif;font-size:13px;font-weight:700;color:var(--brand-navy);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.card-doc .body .date{font-size:11px;color:var(--ink-3);font-weight:600}
.card-doc .body .party{font-size:11px;color:var(--ink-3);font-style:italic;margin-top:2px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.card-doc .body .summary{font-size:11.5px;color:#666;font-family:'Merriweather',serif;font-style:italic;line-height:1.45;margin-top:6px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.card-doc .body .tags{margin-top:auto;padding-top:10px;display:flex;gap:4px;flex-wrap:wrap}
</style>
</head>
<body>
<div class="spec-meta">
<div class="spec-meta-inner">
<div>
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
</div>
<div class="spec-meta-right">
<div><strong>Concept</strong>Gallery Cards</div>
<div><strong>Spec</strong>4 / 5</div>
<div><strong>Effort</strong>Large — structural change + thumbnail service</div>
</div>
</div>
</div>
<nav class="spec-nav">
<div class="spec-nav-inner">
<span class="lbl">Specs</span>
<a href="index.html">Overview</a>
<a href="01-rich-rows.html">1 · Rich Rows</a>
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
<a href="03-master-detail.html">3 · Master-Detail Split</a>
<a class="on" href="04-gallery-cards.html">4 · Gallery Cards</a>
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
</div>
</nav>
<div class="page-wrap">
<div class="hdr">
<div class="hdr-logo">FAMILIENARCHIV</div>
<div class="hdr-nav"><a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a></div>
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
</div>
<div class="page">
<div class="concept-intro">
<h2>Concept 4 · Gallery Cards — grid of letter cards, album style</h2>
Replace the list with a 4-column (collapses to 3 → 2) grid. Each card is a miniature letter card with thumbnail, title, date, correspondent, and a 3-line summary. Year bands replace dividers. The page becomes a family-letter-album.
<div><span class="gain">✚ Beautiful browsing for archive overview</span><span class="gain">✚ Most "filled" of all concepts — no empty space</span><span class="cost"> Chronological scanning is worse than lists</span><span class="cost"> 851 letters → ~213 rows in grid, long page or needs paging</span></div>
</div>
<!-- Filter card -->
<div class="card">
<div class="filter-row">
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
</div>
<div class="filter-actions">
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
<div class="view-toggle"><span>☰ Liste</span><span class="on">▦ Galerie</span></div>
<div class="count"><b>851</b> Briefe</div>
</div>
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
</div>
<div class="year-band">1940 <span class="n">1 Brief</span></div>
<div class="grid">
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge in">← eingehend</span>
</div>
<div class="body">
<h3>Demo leserlicher Brief</h3>
<div class="date">31. Mai 1940 · Belgard</div>
<div class="party">von <b>Gertrud von Rofden</b></div>
<div class="summary">„letzte Lebenstage von W. Dörpfeld in Griechenland — ausführlicher Bericht"</div>
<div class="tags"><span class="tag">Dörpfeld</span><span class="tag">Griechenland</span></div>
</div>
</div>
</div>
<div class="year-band">1923 <span class="n">5 Briefe</span></div>
<div class="grid">
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge">→ ausgehend</span>
</div>
<div class="body">
<h3>W-0397 2. September 1923 B.Lichterfelde</h3>
<div class="date">2. September 1923 · B.Lichterfelde</div>
<div class="party">an <b>Herbert Cram</b></div>
<div class="summary">„von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte"</div>
<div class="tags"><span class="tag">Verlag</span><span class="tag">Familie</span></div>
</div>
</div>
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge">→ ausgehend</span>
</div>
<div class="body">
<h3>W-0396 2. September 1923 B.Lichterfelde</h3>
<div class="date">2. September 1923 · B.Lichterfelde</div>
<div class="party">an <b>Herbert Cram</b></div>
<div class="tags"></div>
</div>
</div>
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge">→ ausgehend</span>
</div>
<div class="body">
<h3>W-0524 31. Juli 1923 Berlin</h3>
<div class="date">31. Juli 1923 · Berlin</div>
<div class="party">an <b>Walter Dieckmann</b></div>
<div class="summary">„Glückwunsch zum 60. Geburtstag, Bericht über den Verlag"</div>
<div class="tags"><span class="tag">Geburtstag</span><span class="tag">Verlag</span></div>
</div>
</div>
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge">→ ausgehend</span>
</div>
<div class="body">
<h3>W-0523 12. Mai 1923 Berlin</h3>
<div class="date">12. Mai 1923 · Berlin</div>
<div class="party">an <b>Walter Dieckmann</b></div>
<div class="summary">„Routinebericht des Verlagsgeschäfts, Rückfragen zu Auftragslage"</div>
<div class="tags"><span class="tag">Verlag</span></div>
</div>
</div>
</div>
<div class="year-band">1922 <span class="n">37 Briefe</span></div>
<div class="grid">
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge">→ ausgehend</span>
</div>
<div class="body">
<h3>W-0521 24. Dezember 1922 Berlin</h3>
<div class="date">24. Dezember 1922 · Berlin</div>
<div class="party">an <b>Walter Dieckmann</b></div>
<div class="summary">„Weihnachtsbrief, Erinnerungen an das Jahr und Bitte um ein Bild der Kinder"</div>
<div class="tags"><span class="tag">Weihnachten</span><span class="tag">Familie</span></div>
</div>
</div>
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge">→ ausgehend</span>
</div>
<div class="body">
<h3>W-0392 23. Nov 1921 Bad Kissingen</h3>
<div class="date">23. November 1921 · Bad Kissingen</div>
<div class="party">an <b>Herbert Cram</b></div>
<div class="summary">„Kurbericht, Gesundheitsupdate, Grüße an die Familie"</div>
<div class="tags"><span class="tag">Kuraufenthalt</span></div>
</div>
</div>
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge">→ ausgehend</span>
</div>
<div class="body">
<h3>W-0501 13. Dez 1920 Berlin</h3>
<div class="date">13. Dezember 1920 · Berlin</div>
<div class="party">an <b>Walter Dieckmann</b></div>
<div class="tags"></div>
</div>
</div>
<div class="card-doc">
<div class="thumb-wrap">
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<span class="dir-badge">→ ausgehend</span>
</div>
<div class="body">
<h3>W-0484 27. Dez 1919 Berlin</h3>
<div class="date">27. Dezember 1919 · Berlin</div>
<div class="party">an <b>Walter Dieckmann</b></div>
<div class="summary">„Jahreswechselgrüße, kurzer Bericht über den Zustand des Verlags"</div>
<div class="tags"><span class="tag">Neujahr</span></div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,303 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spec 5 — Person Dashboard · Briefwechsel Insights</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="_shared.css">
<style>
/* Spec 5 specific */
.person-grid{display:grid;grid-template-columns:35% 1fr;gap:32px;align-items:start}
@media (max-width: 1100px){ .person-grid{grid-template-columns:1fr} }
/* Person card (left) */
.pcard{background:#fff;border:1px solid var(--line);border-radius:2px;padding:24px;display:flex;flex-direction:column;align-items:center;gap:14px}
.pavatar{width:100px;height:100px;border-radius:50%;background:var(--brand-mint);color:var(--brand-navy);display:flex;align-items:center;justify-content:center;font-size:36px;font-weight:900;font-family:'Merriweather',serif}
.pname{font-family:'Merriweather',serif;font-size:22px;font-weight:700;color:var(--brand-navy);text-align:center;line-height:1.3}
.pdates{font-size:12px;color:#888;font-weight:600;letter-spacing:.5px}
.pnotes{font-size:13px;color:#555;line-height:1.6;margin-top:6px;padding-top:14px;border-top:1px dashed var(--line);width:100%;font-family:'Merriweather',serif;font-style:italic}
.pactions{margin-top:14px;display:flex;gap:8px;width:100%}
.pactions .btn{flex:1;justify-content:center;font-size:10px}
.pactions .btn.primary{background:var(--brand-navy);color:#fff;border-color:var(--brand-navy)}
/* Name history card */
.ncard{background:#fff;border:1px solid var(--line);border-radius:2px;padding:18px 20px;margin-top:20px}
.ncard h3{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:10px}
.ncard ul{list-style:none}
.ncard li{padding:5px 0;font-size:12.5px;color:#444;border-top:1px dashed var(--line);display:flex;justify-content:space-between}
.ncard li:first-child{border-top:0}
/* Right column */
.dash{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
.dash-hdr{background:var(--brand-navy);color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:center}
.dash-hdr h2{font-family:'Merriweather',serif;font-size:16px;font-weight:700}
.dash-hdr .open-conv{background:var(--brand-mint);color:var(--brand-navy);font-size:11px;font-weight:800;padding:6px 14px;border-radius:2px;text-transform:uppercase;letter-spacing:.6px;text-decoration:none}
/* Stat strip */
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line-2);border-bottom:1px solid var(--line)}
.stats div{background:#fafaf5;padding:14px 18px;text-align:center}
.stats .v{font-family:'Merriweather',serif;font-size:22px;font-weight:900;color:var(--brand-navy);letter-spacing:-.5px}
.stats .k{font-size:10px;color:#888;font-weight:700;text-transform:uppercase;letter-spacing:.6px;margin-top:2px}
.stats .out{color:var(--primary)}
.stats .in{color:var(--accent)}
/* Sections */
.dsec{padding:18px 22px;border-top:1px solid var(--line-2)}
.dsec:first-of-type{border-top:0}
.dsec h3{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:12px;display:flex;justify-content:space-between;align-items:baseline}
.dsec h3 .note{font-size:11px;color:#555;text-transform:none;letter-spacing:0;font-weight:600}
/* Activity histogram */
.hist{display:flex;align-items:flex-end;gap:2px;height:90px;padding:4px 0 0}
.hist .bar{flex:1;background:var(--brand-mint);opacity:.55;border-radius:1px 1px 0 0;position:relative;cursor:pointer;transition:opacity .12s}
.hist .bar:hover{opacity:1}
.hist .bar.peak{background:var(--brand-navy);opacity:.85}
.hist-labels{display:flex;justify-content:space-between;font-size:10px;color:#888;margin-top:6px;font-weight:700}
/* Split bar direction */
.dsplit{display:flex;justify-content:space-between;font-size:12px;font-weight:700;margin-bottom:8px}
.dsplit .out{color:var(--primary)}
.dsplit .in{color:var(--accent)}
.dbar{height:10px;display:flex;border-radius:5px;overflow:hidden;background:#F0EDE5}
.dbar .out{background:var(--brand-navy)}
.dbar .in{background:var(--accent)}
/* Top list */
.toplist{display:flex;flex-direction:column;gap:8px}
.toplist .ti{display:flex;align-items:center;gap:10px;font-size:13px;padding:4px 6px;border-radius:2px;cursor:pointer}
.toplist .ti:hover{background:var(--muted)}
.toplist .ti .name{flex:1;color:var(--ink);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.toplist .ti .bar-wrap{width:120px;height:7px;background:#F0EDE5;border-radius:4px;overflow:hidden;flex-shrink:0}
.toplist .ti .bar{height:100%;background:var(--brand-navy);border-radius:4px}
.toplist .ti .val{width:38px;text-align:right;font-size:12px;color:#888;font-weight:700;font-variant-numeric:tabular-nums}
.toplist .ti .dir{font-size:13px;width:16px;font-weight:800}
.toplist .ti .dir.out{color:var(--primary)}
.toplist .ti .dir.in{color:var(--accent)}
.toplist .ti .dir.both{color:#888}
/* Cloud */
.cloud{display:flex;flex-wrap:wrap;gap:6px}
.cloud .tag{cursor:pointer;padding:3px 10px;border-radius:12px;font-weight:700;transition:transform .1s}
.cloud .tag:hover{transform:translateY(-1px)}
.cloud .tag.s-xl{font-size:15px;padding:4px 12px}
.cloud .tag.s-l{font-size:13px}
.cloud .tag.s-m{font-size:12px}
.cloud .tag.s-s{font-size:11px}
/* Two-col arrangement */
.twocol{display:grid;grid-template-columns:1fr 1fr;gap:32px}
@media (max-width: 900px){ .twocol{grid-template-columns:1fr} }
/* Existing doc lists below (compressed) */
.doclist-card{background:#fff;border:1px solid var(--line);border-radius:2px;margin-top:20px;overflow:hidden}
.doclist-card .dh{padding:14px 20px;border-bottom:1px solid var(--line-2);font-size:13px;font-weight:700;color:var(--brand-navy);display:flex;justify-content:space-between;align-items:center}
.doclist-card .dh .cnt{font-size:11px;color:#888;font-weight:600}
.doclist-card .di{padding:10px 20px;border-bottom:1px solid var(--line-2);display:flex;justify-content:space-between;align-items:center;font-size:12.5px}
.doclist-card .di:last-child{border-bottom:0}
.doclist-card .di .t{font-family:'Merriweather',serif;font-weight:700;color:var(--ink);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;margin-right:10px}
.doclist-card .di .m{color:#888;font-size:11px;white-space:nowrap}
</style>
</head>
<body>
<div class="spec-meta">
<div class="spec-meta-inner">
<div>
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
<p>Insights belong on the person detail page, not on the letter list. This spec mocks the dashboard that replaces the "empty feeling" problem at its proper home.</p>
</div>
<div class="spec-meta-right">
<div><strong>Concept</strong>Person Dashboard</div>
<div><strong>Spec</strong>5 / 5</div>
<div><strong>Page</strong>/persons/[id] (not /briefwechsel)</div>
</div>
</div>
</div>
<nav class="spec-nav">
<div class="spec-nav-inner">
<span class="lbl">Specs</span>
<a href="index.html">Overview</a>
<a href="01-rich-rows.html">1 · Rich Rows</a>
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
<a href="03-master-detail.html">3 · Master-Detail Split</a>
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
<a class="on" href="05-person-dashboard.html">5 · Person Dashboard</a>
</div>
</nav>
<div class="page-wrap">
<div class="hdr">
<div class="hdr-logo">FAMILIENARCHIV</div>
<div class="hdr-nav"><a>Documents</a><a class="on">Persons</a><a>Letters</a><a>Admin</a></div>
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
</div>
<div class="page">
<div class="concept-intro">
<h2>Concept 5 · Person Dashboard — insights live on /persons/[id], not on /briefwechsel</h2>
The /briefwechsel page stays focused on reading letters. The archive-level understanding (how much, with whom, when, about what) is moved to the person detail page, where it's useful in all contexts — not only when a correspondent is selected. A "Briefwechsel öffnen" button ties the two pages together.
<div><span class="gain">✚ /briefwechsel stays calm and focused</span><span class="gain">✚ Dashboard is useful on every visit to a person, not just during letter review</span><span class="gain">✚ Each dashboard element links back into /briefwechsel with filters</span><span class="cost"> Separates the work over two pages — needs a clear handoff button</span></div>
</div>
<div style="font-size:12px;color:#888;margin-bottom:18px"><a href="#" style="color:#888;text-decoration:none">← Zurück</a></div>
<div class="person-grid">
<!-- Left column: person identity -->
<div>
<div class="pcard">
<div class="pavatar">WG</div>
<div class="pname">Walter de Gruyter</div>
<div class="pdates">1862 1923</div>
<div class="pnotes">Verleger und Namensgeber des Verlags Walter de Gruyter. Langjährige Korrespondenz mit Familie Dieckmann und Herbert Cram rund um Verlag, Familie und Kuraufenthalte.</div>
<div class="pactions">
<a class="btn">◎ Bearbeiten</a>
<a class="btn primary">↗ Briefwechsel öffnen</a>
</div>
</div>
<div class="ncard">
<h3>Namens­varianten</h3>
<ul>
<li><span>Walter de Gruyter</span><span style="color:#888">Hauptname</span></li>
<li><span>W. de Gruyter</span><span style="color:#888">Abkürzung</span></li>
<li><span>Dr. Walter de Gruyter</span><span style="color:#888">mit Titel</span></li>
</ul>
</div>
</div>
<!-- Right column: dashboard -->
<div>
<div class="dash">
<div class="dash-hdr">
<h2>Korrespondenz-Überblick</h2>
<a class="open-conv" href="#">↗ Briefwechsel öffnen</a>
</div>
<div class="stats">
<div><div class="v">851</div><div class="k">Briefe gesamt</div></div>
<div><div class="v out">612</div><div class="k out">ausgehend</div></div>
<div><div class="v in">239</div><div class="k in">eingehend</div></div>
<div><div class="v">42</div><div class="k">Jahre</div></div>
</div>
<div class="dsec">
<h3>Aktivität über die Jahre <span class="note">Spitzenjahr <b style="color:var(--brand-navy)">1922 · 78 Briefe</b></span></h3>
<div class="hist">
<div class="bar" style="height:12%" title="1898 · 8"></div>
<div class="bar" style="height:18%" title="1899 · 12"></div>
<div class="bar" style="height:26%" title="1900 · 18"></div>
<div class="bar" style="height:38%" title="1901 · 26"></div>
<div class="bar" style="height:44%" title="1902 · 30"></div>
<div class="bar" style="height:52%" title="1903 · 36"></div>
<div class="bar" style="height:60%" title="1904 · 42"></div>
<div class="bar" style="height:68%" title="1905 · 48"></div>
<div class="bar" style="height:80%" title="1920 · 62"></div>
<div class="bar" style="height:88%" title="1921 · 68"></div>
<div class="bar peak" style="height:100%" title="1922 · 78"></div>
<div class="bar" style="height:72%" title="1923 · 54"></div>
<div class="bar" style="height:58%" title="1924 · 42"></div>
<div class="bar" style="height:48%" title="1925 · 34"></div>
<div class="bar" style="height:38%" title="1926 · 27"></div>
<div class="bar" style="height:28%" title="1927 · 20"></div>
<div class="bar" style="height:22%" title="1928 · 16"></div>
<div class="bar" style="height:18%" title="1929 · 13"></div>
<div class="bar" style="height:14%" title="1930 · 10"></div>
<div class="bar" style="height:10%" title="1932 · 7"></div>
<div class="bar" style="height:6%" title="1935 · 4"></div>
<div class="bar" style="height:4%" title="1938 · 3"></div>
<div class="bar" style="height:2%" title="1940 · 1"></div>
</div>
<div class="hist-labels"><span>1898</span><span>1922 ▲</span><span>1940</span></div>
</div>
<div class="dsec">
<h3>Richtungsverteilung</h3>
<div class="dsplit">
<span class="out">→ 612 ausgehend · 72%</span>
<span class="in">← 239 eingehend · 28%</span>
</div>
<div class="dbar">
<span class="out" style="width:72%"></span>
<span class="in" style="width:28%"></span>
</div>
</div>
<div class="twocol">
<div class="dsec" style="border-top:1px solid var(--line-2)">
<h3>Häufigste Korrespondenten <span class="note">Top 6 von 87</span></h3>
<div class="toplist">
<div class="ti"><span class="dir both"></span><span class="name">Walter Dieckmann</span><span class="bar-wrap"><span class="bar" style="width:100%"></span></span><span class="val">184</span></div>
<div class="ti"><span class="dir both"></span><span class="name">Herbert Cram</span><span class="bar-wrap"><span class="bar" style="width:78%"></span></span><span class="val">143</span></div>
<div class="ti"><span class="dir both"></span><span class="name">Ella Dieckmann</span><span class="bar-wrap"><span class="bar" style="width:48%"></span></span><span class="val">88</span></div>
<div class="ti"><span class="dir both"></span><span class="name">Eugenie de Gruyter</span><span class="bar-wrap"><span class="bar" style="width:42%"></span></span><span class="val">77</span></div>
<div class="ti"><span class="dir both"></span><span class="name">Gertrud von Rofden</span><span class="bar-wrap"><span class="bar" style="width:32%"></span></span><span class="val">58</span></div>
<div class="ti"><span class="dir both"></span><span class="name">Käthe Dieckmann</span><span class="bar-wrap"><span class="bar" style="width:26%"></span></span><span class="val">47</span></div>
</div>
<div style="margin-top:10px"><a style="font-size:11px;color:var(--primary);font-weight:700;text-decoration:none;border-bottom:1px dashed var(--primary)">Alle 87 Korrespondenten →</a></div>
</div>
<div class="dsec" style="border-top:1px solid var(--line-2)">
<h3>Häufigste Orte <span class="note">Top 5 von 42</span></h3>
<div class="toplist">
<div class="ti"><span class="name">📍 Berlin</span><span class="bar-wrap"><span class="bar" style="width:100%"></span></span><span class="val">412</span></div>
<div class="ti"><span class="name">📍 B.Lichterfelde</span><span class="bar-wrap"><span class="bar" style="width:44%"></span></span><span class="val">180</span></div>
<div class="ti"><span class="name">📍 Bad Kissingen</span><span class="bar-wrap"><span class="bar" style="width:14%"></span></span><span class="val">58</span></div>
<div class="ti"><span class="name">📍 Cöln</span><span class="bar-wrap"><span class="bar" style="width:9%"></span></span><span class="val">37</span></div>
<div class="ti"><span class="name">📍 Belgard</span><span class="bar-wrap"><span class="bar" style="width:6%"></span></span><span class="val">26</span></div>
</div>
</div>
</div>
<div class="dsec">
<h3>Beliebte Schlagwörter <span class="note">Klick filtert den Briefwechsel</span></h3>
<div class="cloud">
<span class="tag s-xl">Verlag</span>
<span class="tag s-xl">Familie</span>
<span class="tag s-l">Geburtstag</span>
<span class="tag s-l">Weihnachten</span>
<span class="tag s-m">Kuraufenthalt</span>
<span class="tag s-m">Reise</span>
<span class="tag s-m">Geschäft</span>
<span class="tag s-s">Krieg</span>
<span class="tag s-s muted">Krankheit</span>
<span class="tag s-s muted">Schule</span>
<span class="tag s-s muted">Hochzeit</span>
<span class="tag s-s muted">Tod</span>
<span class="tag s-s muted">Namenstag</span>
<span class="tag s-s muted">Neujahr</span>
</div>
</div>
</div>
<!-- Existing doc lists stay below the dashboard -->
<div class="doclist-card">
<div class="dh">Geschriebene Briefe <span class="cnt">612 Briefe · <a style="color:var(--primary);text-decoration:none;border-bottom:1px dashed">Alle anzeigen →</a></span></div>
<div class="di"><span class="t">W-0397 2. September 1923 B.Lichterfelde</span><span class="m">an Herbert Cram</span></div>
<div class="di"><span class="t">W-0521 24. Dezember 1922 Berlin</span><span class="m">an Walter Dieckmann</span></div>
<div class="di"><span class="t">W-0392 23. November 1921 Bad Kissingen</span><span class="m">an Herbert Cram</span></div>
</div>
<div class="doclist-card">
<div class="dh">Empfangene Briefe <span class="cnt">239 Briefe · <a style="color:var(--primary);text-decoration:none;border-bottom:1px dashed">Alle anzeigen →</a></span></div>
<div class="di"><span class="t">Demo leserlicher Brief</span><span class="m">von Gertrud von Rofden</span></div>
</div>
</div>
</div>
<div style="margin-top:36px;padding:16px 20px;background:#fff;border-left:4px solid var(--brand-navy);font-size:13px;color:#333;line-height:1.65">
<b style="color:var(--brand-navy)">How this ties back to /briefwechsel:</b>
<ul style="margin:8px 0 0 18px;padding:0">
<li>Every correspondent row → <code>/briefwechsel?senderId=&lt;person&gt;&amp;receiverId=&lt;other&gt;</code> (bilateral view)</li>
<li>Every location → <code>/briefwechsel?senderId=&lt;person&gt;&amp;location=&lt;x&gt;</code></li>
<li>Every tag → <code>/briefwechsel?senderId=&lt;person&gt;&amp;tag=&lt;x&gt;</code></li>
<li>Every histogram year → <code>/briefwechsel?…&amp;from=YYYY-01-01&amp;to=YYYY-12-31</code></li>
</ul>
The dashboard is the discovery surface; /briefwechsel is the reading surface.
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,102 @@
/* Shared CSS for briefwechsel-fill specs */
:root {
--brand-navy: #002850;
--brand-mint: #A6DAD8;
--brand-sand: #E4E2D7;
--bg: #ECEAE4;
--surface: #ffffff;
--line: #E4E2D7;
--line-2: #EFEDE6;
--muted: #F7F5F2;
--ink: #1A1A1A;
--ink-3: #666;
--primary: #002850;
--accent: #2F9E95;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5}
.font-serif{font-family:'Merriweather',Georgia,serif}
/* ── Spec meta header ─── */
.spec-meta{background:var(--brand-navy);color:#fff;padding:18px 28px;border-bottom:3px solid var(--brand-mint)}
.spec-meta-inner{max-width:1440px;margin:0 auto;display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap}
.spec-meta h1{font-size:18px;font-weight:800;letter-spacing:.4px}
.spec-meta h1 span{opacity:.5;font-weight:400}
.spec-meta p{font-size:12px;color:rgba(255,255,255,.7);margin-top:4px;max-width:600px}
.spec-meta-right{font-size:11px;display:flex;gap:20px;flex-wrap:wrap}
.spec-meta-right div strong{display:block;font-size:9px;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.45);font-weight:800;margin-bottom:2px}
.spec-nav{background:#0a1e36;padding:10px 28px;border-bottom:1px solid rgba(255,255,255,.08)}
.spec-nav-inner{max-width:1440px;margin:0 auto;display:flex;gap:4px;font-size:11px;flex-wrap:wrap;align-items:center}
.spec-nav a{color:rgba(255,255,255,.55);text-decoration:none;padding:4px 10px;border-radius:3px;font-weight:600}
.spec-nav a:hover{color:#fff;background:rgba(255,255,255,.06)}
.spec-nav a.on{color:var(--brand-navy);background:var(--brand-mint)}
.spec-nav .lbl{color:rgba(255,255,255,.3);font-size:9px;text-transform:uppercase;letter-spacing:1px;margin-right:8px}
/* ── Real page chrome ─── */
.page-wrap{background:var(--bg);min-height:calc(100vh - 120px)}
.hdr{background:var(--brand-navy);color:#fff;padding:0 32px;height:64px;display:flex;align-items:center;gap:40px}
.hdr-logo{font-size:16px;font-weight:900;letter-spacing:1.5px}
.hdr-nav{display:flex;gap:28px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.8px}
.hdr-nav a{color:rgba(255,255,255,.7);text-decoration:none;padding:4px 0;border-bottom:2px solid transparent}
.hdr-nav a.on{color:#fff;border-color:var(--brand-mint)}
.hdr-right{margin-left:auto;display:flex;gap:14px;align-items:center;font-size:11px;color:rgba(255,255,255,.65)}
.hdr-upload{border:1px solid rgba(255,255,255,.3);border-radius:4px;padding:6px 12px;font-weight:700;letter-spacing:.5px}
.hdr-avatar{width:32px;height:32px;border-radius:50%;background:var(--brand-mint);color:var(--brand-navy);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:12px}
.page{max-width:1440px;margin:0 auto;padding:28px 32px}
.card{background:var(--surface);border:1px solid var(--line);border-radius:2px;box-shadow:0 1px 2px rgba(0,0,0,.02);padding:20px 24px;margin-bottom:24px}
.filter-row{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:14px}
.fl{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:6px}
.fi{height:40px;border:1px solid #D1D5DB;border-radius:2px;background:#fff;padding:0 12px;font-size:13px;display:flex;align-items:center;font-family:'Merriweather',serif}
.fi.empty{color:#bbb;font-style:italic}
.filter-actions{display:flex;gap:8px;align-items:center}
.btn{height:34px;border:1px solid #D1D5DB;background:#F7F5F2;border-radius:2px;padding:0 12px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#444;display:inline-flex;align-items:center;gap:6px}
.count{margin-left:auto;font-size:13px;color:#555}
.count b{color:var(--brand-navy)}
.hintbar{margin-top:14px;background:#e9f5f4;border:1px solid #c8e6e4;border-radius:2px;padding:10px 14px;font-size:12px;color:#1a3e3c;display:flex;align-items:center;gap:8px}
.hintbar b{color:var(--brand-navy)}
/* ── Year divider ─── */
.year-divider{display:flex;align-items:baseline;gap:12px;background:var(--muted);border-top:2px solid var(--line);border-bottom:1px solid var(--line);padding:8px 14px}
.year-divider .y{font-size:22px;font-weight:900;color:var(--brand-navy);letter-spacing:-.5px}
.year-divider .n{font-size:12px;font-weight:700;color:#888}
/* ── Status dots ─── */
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;flex-shrink:0}
.dot.uploaded{background:var(--brand-mint)}
.dot.transcribed{background:var(--brand-mint)}
.dot.reviewed{background:rgba(0,40,80,.55)}
.dot.archived{background:var(--brand-navy)}
/* ── Direction indicator ─── */
.dir{width:16px;height:16px;flex-shrink:0;opacity:.6}
.dir-out{color:var(--primary)}
.dir-in{color:var(--accent)}
/* ── Concept intro banner ─── */
.concept-intro{background:#fff;border-left:4px solid var(--brand-mint);padding:14px 20px;margin-bottom:24px;font-size:13px;color:#333;border-radius:2px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.concept-intro h2{font-size:14px;font-weight:800;color:var(--brand-navy);margin-bottom:4px}
.concept-intro .gain,.concept-intro .cost{display:inline-block;font-size:11px;margin-right:14px;margin-top:6px}
.concept-intro .gain{color:#166534}
.concept-intro .cost{color:#92400E}
/* ── Mock letter thumbnail ─── */
.thumb{background:linear-gradient(180deg,#fdfcf7 0%,#f6f3ea 100%);border:1px solid #d9d4c6;box-shadow:inset 0 0 0 1px #fff,0 1px 2px rgba(0,0,0,.05);position:relative;overflow:hidden;flex-shrink:0;border-radius:1px}
.thumb::before{content:'';position:absolute;top:0;left:0;right:0;height:28%;background:linear-gradient(135deg,rgba(166,218,216,.25) 0%,transparent 60%)}
.thumb-lines{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;gap:2px;padding:20% 12% 20% 12%}
.thumb-lines i{display:block;height:1px;background:rgba(0,40,80,.2)}
.thumb-lines i:nth-child(odd){width:95%}
.thumb-lines i:nth-child(3n){width:70%}
.thumb-lines i:nth-child(5n){width:80%}
.thumb.handwritten .thumb-lines{padding:15% 10%;gap:4px}
.thumb.handwritten .thumb-lines i{height:1.2px;background:rgba(0,40,80,.35);transform:rotate(-.6deg)}
/* ── Tag chip ─── */
.tag{display:inline-flex;align-items:center;font-size:10px;font-weight:700;background:var(--brand-mint);color:var(--brand-navy);padding:2px 7px;border-radius:10px;letter-spacing:.3px}
.tag.muted{background:#EEE8DC;color:#666}
/* ── Links & generic ─── */
a{color:inherit}
.hide{display:none}

View File

@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Briefwechsel — Fill the Empty Rows · Overview</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="_shared.css">
<style>
.ov{max-width:1440px;margin:0 auto;padding:36px 32px 80px}
.lead{display:grid;grid-template-columns:1fr 320px;gap:40px;margin-bottom:36px;align-items:start}
@media (max-width: 900px){ .lead{grid-template-columns:1fr} }
.lead h2{font-family:'Merriweather',serif;font-size:24px;font-weight:700;color:var(--brand-navy);margin-bottom:10px;line-height:1.3}
.lead p{color:#444;font-size:14px;line-height:1.65}
.lead .kit{background:#fff;border:1px solid var(--line);border-radius:2px;padding:16px 18px}
.lead .kit h3{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:10px}
.lead .kit li{font-size:12px;padding:5px 0;border-top:1px dashed var(--line);display:flex;justify-content:space-between}
.lead .kit li:first-child{border-top:0}
.lead .kit li b{color:var(--brand-navy);font-weight:700}
.section-h{font-family:'Merriweather',serif;font-size:16px;font-weight:700;color:var(--brand-navy);margin:28px 0 12px;padding-top:20px;border-top:1px dashed var(--line)}
.section-h .sub{font-family:'Montserrat',sans-serif;font-size:12px;color:#888;font-weight:400;margin-left:10px}
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
@media (max-width: 1200px){ .grid4{grid-template-columns:repeat(2,1fr)} }
.grid1{display:grid;grid-template-columns:1fr;gap:16px}
.spec-card{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden;text-decoration:none;color:inherit;display:flex;flex-direction:column;transition:transform .12s,box-shadow .12s}
.spec-card:hover{transform:translateY(-3px);box-shadow:0 8px 26px rgba(0,0,0,.1)}
.spec-card.wide{flex-direction:row}
.spec-card.wide .mini{width:45%;aspect-ratio:auto;min-height:280px;border-right:1px solid var(--line);border-bottom:0}
.spec-card.wide .sc-body{flex:1}
.spec-card .sc-num{background:var(--brand-navy);color:#fff;padding:3px 8px;font-size:10px;font-weight:800;letter-spacing:.8px;display:inline-block;border-radius:0 0 3px 0;width:max-content}
.spec-card .sc-body{padding:14px 16px;flex:1;display:flex;flex-direction:column;gap:8px}
.spec-card h3{font-family:'Merriweather',serif;font-size:16px;font-weight:700;color:var(--brand-navy);line-height:1.35}
.spec-card p{font-size:12px;color:#555;line-height:1.55}
.spec-card .tags{display:flex;gap:4px;flex-wrap:wrap;margin-top:auto;padding-top:8px}
.spec-card .tag{font-size:9.5px;letter-spacing:.3px}
/* Mini previews */
.mini{aspect-ratio:16/10;border-bottom:1px solid var(--line);background:#fafaf5;display:flex;padding:8px;gap:4px}
.mini.col{flex-direction:column}
.mini .minirow{background:#fff;border:1px solid var(--line-2);border-left:2px solid var(--primary);padding:4px 6px;display:flex;flex-direction:column;gap:2px;flex:1;font-size:7px;color:#888}
.mini .minirow b{color:var(--brand-navy);font-size:8px;font-weight:700}
.mini .minirow.in{border-left-color:var(--accent)}
.mini-1{display:flex;flex-direction:column;gap:3px;padding:8px}
.mini-1 .r{background:#fff;border:1px solid var(--line-2);border-left:2px solid var(--primary);padding:4px 6px;display:grid;grid-template-columns:1fr 48px;gap:4px;flex:1;font-size:7px;color:#888;align-items:center}
.mini-1 .r b{color:var(--brand-navy);font-size:8px;font-weight:700;font-family:'Merriweather',serif}
.mini-1 .r .ar{font-size:6.5px;color:#888;background:#F4F1EA;padding:2px 4px;text-align:center}
.mini-1 .r .tg{display:flex;gap:2px;margin-top:1px}
.mini-1 .r .tg span{background:var(--brand-mint);padding:0 3px;font-size:6px;border-radius:3px;color:var(--brand-navy)}
.mini-2{display:flex;flex-direction:column;gap:3px;padding:8px}
.mini-2 .r2{background:#fff;border:1px solid var(--line-2);border-left:2px solid var(--primary);padding:4px;display:flex;gap:4px;align-items:center;flex:1}
.mini-2 .th2{width:22px;height:28px;background:linear-gradient(#fdfcf7,#f1eadb);border:1px solid #d9d4c6;flex-shrink:0;position:relative}
.mini-2 .th2::before{content:'';position:absolute;inset:2px 3px;border-top:1px solid rgba(0,40,80,.25);border-bottom:1px solid rgba(0,40,80,.25);opacity:.6}
.mini-2 .content{flex:1;display:flex;flex-direction:column;gap:2px;font-size:7px;color:#888}
.mini-2 .content b{color:var(--brand-navy);font-size:8px;font-family:'Merriweather',serif}
.mini-2 .content i{color:#555;font-style:italic}
.mini-2 .content .dt{font-size:7px;color:#888;align-self:flex-end;font-family:'Merriweather',serif}
.mini-3{display:grid;grid-template-columns:1fr 1.1fr;gap:4px;padding:8px}
.mini-3 .left-list{display:flex;flex-direction:column;gap:2px}
.mini-3 .left-list .r{background:#fff;border:1px solid var(--line-2);border-left:2px solid var(--primary);padding:2px 4px;font-size:6.5px;color:#888}
.mini-3 .left-list .r.sel{background:#e7f4f3;border-left-color:var(--accent)}
.mini-3 .left-list .r b{color:var(--brand-navy);font-size:7px}
.mini-3 .preview-mini{background:#fff;border:1px solid var(--line-2);padding:5px;display:flex;gap:4px}
.mini-3 .pv-th{width:28px;height:38px;background:linear-gradient(#fdfcf7,#f1eadb);border:1px solid #d9d4c6;flex-shrink:0}
.mini-3 .pv-meta{font-size:6px;color:#888;line-height:1.4}
.mini-3 .pv-meta b{color:var(--brand-navy);font-size:7px;display:block;font-family:'Merriweather',serif}
.mini-4{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;padding:8px}
.mini-4 .card-s{background:#fff;border:1px solid var(--line-2);overflow:hidden;display:flex;flex-direction:column}
.mini-4 .card-s .th{aspect-ratio:3/4;background:linear-gradient(#fdfcf7,#f1eadb);border-bottom:1px solid var(--line-2)}
.mini-4 .card-s .t{font-size:5.5px;color:var(--brand-navy);font-weight:700;padding:3px 4px;font-family:'Merriweather',serif}
.mini-5{display:grid;grid-template-columns:32% 1fr;gap:6px;padding:10px;align-items:start;min-height:260px}
.mini-5 .person-left{background:#fff;border:1px solid var(--line-2);padding:8px;display:flex;flex-direction:column;align-items:center;gap:4px}
.mini-5 .person-left .av{width:28px;height:28px;border-radius:50%;background:var(--brand-mint);color:var(--brand-navy);display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:800;font-family:'Merriweather',serif}
.mini-5 .person-left .nm{font-family:'Merriweather',serif;font-size:7.5px;font-weight:700;color:var(--brand-navy);text-align:center}
.mini-5 .person-left .dt{font-size:6px;color:#888}
.mini-5 .dash-mini{background:#fff;border:1px solid var(--line-2);display:flex;flex-direction:column}
.mini-5 .dh{background:var(--brand-navy);color:#fff;font-family:'Merriweather',serif;font-size:6.5px;padding:4px 6px;display:flex;justify-content:space-between;align-items:center}
.mini-5 .dh span{background:var(--brand-mint);color:var(--brand-navy);font-size:5.5px;padding:1px 4px;border-radius:2px;font-weight:800}
.mini-5 .stats-m{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line-2);border-bottom:1px solid var(--line-2)}
.mini-5 .stats-m div{background:#fafaf5;padding:3px;text-align:center}
.mini-5 .stats-m .v{font-family:'Merriweather',serif;font-size:8px;font-weight:900;color:var(--brand-navy)}
.mini-5 .stats-m .k{font-size:5px;color:#888;font-weight:700}
.mini-5 .hist-m{display:flex;align-items:flex-end;gap:1px;height:22px;padding:4px 5px 0}
.mini-5 .hist-m i{flex:1;background:var(--brand-mint);opacity:.6}
.mini-5 .hist-m i.p{background:var(--brand-navy);opacity:.9}
.mini-5 .bars-m{display:flex;flex-direction:column;gap:1.5px;padding:4px 5px}
.mini-5 .bars-m span{display:flex;align-items:center;gap:2px;font-size:5.5px;color:#555}
.mini-5 .bars-m span .bar{flex:1;height:2px;background:var(--brand-navy);border-radius:1px}
.mini-5 .cloud-m{padding:4px 5px;display:flex;flex-wrap:wrap;gap:2px}
.mini-5 .cloud-m span{background:var(--brand-mint);padding:0 3px;border-radius:3px;font-size:5.5px;color:var(--brand-navy);font-weight:700}
/* Comparison table */
.cmp{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden;margin-top:36px}
.cmp table{width:100%;border-collapse:collapse}
.cmp th,.cmp td{padding:10px 14px;text-align:left;font-size:12px;border-bottom:1px solid var(--line-2);vertical-align:top}
.cmp th{background:#fafaf5;font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:#888;font-weight:800}
.cmp td:first-child{font-weight:700;color:var(--brand-navy);font-family:'Merriweather',serif}
.cmp .yes{color:#166534}
.cmp .no{color:#B91C1C}
.cmp .mid{color:#92400E}
</style>
</head>
<body>
<div class="spec-meta">
<div class="spec-meta-inner">
<div>
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
<p>Five approaches. The first four change the <b>/briefwechsel</b> row itself; the fifth puts insights where they belong — on the person detail page.</p>
</div>
<div class="spec-meta-right">
<div><strong>Page</strong>/briefwechsel &nbsp;·&nbsp; /persons/[id]</div>
<div><strong>Brief</strong>Rows feel sparse — ~60% of row width is empty</div>
</div>
</div>
</div>
<nav class="spec-nav">
<div class="spec-nav-inner">
<span class="lbl">Specs</span>
<a class="on" href="index.html">Overview</a>
<a href="01-rich-rows.html">1 · Rich Rows</a>
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
<a href="03-master-detail.html">3 · Master-Detail Split</a>
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
</div>
</nav>
<div class="ov">
<div class="lead">
<div>
<h2>The situation</h2>
<p>On <code>/briefwechsel?senderId=…</code> (e.g. 851 letters for Walter de Gruyter), each row shows only title, date, location and counterpart — leaving the right half empty. The question is: what belongs there?</p>
<p style="margin-top:10px">The five specs answer differently. Specs 14 rework the row itself. <b>Spec 5</b> argues the archive-level view (top correspondents, activity, tag cloud) belongs on <code>/persons/[id]</code>, not here — and mocks the dashboard that lives there instead.</p>
</div>
<div class="kit">
<h3>Data we can use today</h3>
<ul style="list-style:none">
<li>Title / Filename <b></b></li>
<li>Document date <b></b></li>
<li>Location <b></b></li>
<li>Sender / receivers <b></b></li>
<li>Summary text <b></b></li>
<li>Tags <b></b></li>
<li>Archive box &amp; folder <b></b></li>
<li>PDF thumbnail <b>open issue</b></li>
</ul>
<p style="font-size:11px;color:#888;margin-top:10px;font-style:italic">Removed from earlier drafts: status lifecycle (will be dropped from the product) and script type (only set after OCR, unreliable).</p>
</div>
</div>
<div class="section-h">Concepts that rework /briefwechsel <span class="sub">Specs 14</span></div>
<div class="grid4">
<a class="spec-card" href="01-rich-rows.html">
<span class="sc-num">01</span>
<div class="mini mini-1">
<div class="r"><span><b>Demo leserlicher Brief</b><br>31. Mai 1940 · Belgard<br>„letzte Lebenstage…"<div class="tg"><span>Dörpfeld</span></div></span><div class="ar">VII · 5</div></div>
<div class="r"><span><b>W-0397 2. Sep 1923</b><br>B.Lichterfelde · H. Cram<br>„von Elsbeth…"<div class="tg"><span>Verlag</span></div></span><div class="ar">VI · 7</div></div>
<div class="r"><span><b>W-0521 24. Dez 1922</b><br>Berlin · W. Dieckmann<br>„Weihnachtsbrief…"<div class="tg"><span>Weihn.</span></div></span><div class="ar">V · 3</div></div>
</div>
<div class="sc-body">
<h3>Rich Rows</h3>
<p>Pack summary, tags and archive box into each row. No images, no structural change. Fastest to ship.</p>
<div class="tags"><span class="tag">Scanning</span><span class="tag muted">Small effort</span></div>
</div>
</a>
<a class="spec-card" href="02-thumbnail-rows.html">
<span class="sc-num">02</span>
<div class="mini mini-2">
<div class="r2"><div class="th2"></div><div class="content"><b>Demo leserlicher Brief</b><i>„letzte Lebenstage von W. Dörpfeld…"</i><span>← Gertrud · Belgard</span><span class="dt">31. Mai 1940</span></div></div>
<div class="r2"><div class="th2"></div><div class="content"><b>W-0397 2. Sep 1923</b><i>„von Elsbeth geschriebener Kommentar…"</i><span>→ H. Cram · B.Lichterfelde</span><span class="dt">2. Sep 1923</span></div></div>
<div class="r2"><div class="th2"></div><div class="content"><b>W-0521 24. Dez 1922</b><i>„Weihnachtsbrief, Bitte um Bild…"</i><span>→ W. Dieckmann · Berlin</span><span class="dt">24. Dez 1922</span></div></div>
</div>
<div class="sc-body">
<h3>Thumbnail Rows</h3>
<p>PDF preview on the left anchors each row. Summary (when filled) becomes the readable context line.</p>
<div class="tags"><span class="tag">Recognition</span><span class="tag muted">Needs thumbnails</span></div>
</div>
</a>
<a class="spec-card" href="03-master-detail.html">
<span class="sc-num">03</span>
<div class="mini mini-3">
<div class="left-list">
<div class="r"><b>Demo Brief</b></div>
<div class="r sel"><b>W-0397</b></div>
<div class="r"><b>W-0396</b></div>
<div class="r"><b>W-0524</b></div>
<div class="r"><b>W-0523</b></div>
</div>
<div class="preview-mini">
<div class="pv-th"></div>
<div class="pv-meta"><b>W-0397</b>2. Sep 1923<br>B.Lichterfelde<br>→ H. Cram<br><br>„von Elsbeth…"</div>
</div>
</div>
<div class="sc-body">
<h3>Master-Detail Split</h3>
<p>Compact list left, sticky preview right. Click a row → thumbnail, metadata, summary, excerpt. Browse without losing context.</p>
<div class="tags"><span class="tag">Reading flow</span><span class="tag muted">Mobile pattern needed</span></div>
</div>
</a>
<a class="spec-card" href="04-gallery-cards.html">
<span class="sc-num">04</span>
<div class="mini mini-4">
<div class="card-s"><div class="th"></div><div class="t">Demo Brief</div></div>
<div class="card-s"><div class="th"></div><div class="t">W-0397 · 2 Sep</div></div>
<div class="card-s"><div class="th"></div><div class="t">W-0396 · 2 Sep</div></div>
<div class="card-s"><div class="th"></div><div class="t">W-0524 · 31 Jul</div></div>
<div class="card-s"><div class="th"></div><div class="t">W-0523 · 12 Mai</div></div>
<div class="card-s"><div class="th"></div><div class="t">W-0522 · 7 Mär</div></div>
</div>
<div class="sc-body">
<h3>Gallery Cards</h3>
<p>Abandon the list for a 4-column grid. Thumbnail-first, family-album feel. Biggest visual change.</p>
<div class="tags"><span class="tag">Browsing</span><span class="tag muted">Poor date-scanning</span></div>
</div>
</a>
</div>
<div class="section-h">The archive-level view moves to /persons/[id] <span class="sub">Spec 5 · separate page</span></div>
<div class="grid1">
<a class="spec-card wide" href="05-person-dashboard.html">
<span class="sc-num" style="position:absolute">05</span>
<div class="mini mini-5">
<div class="person-left">
<div class="av">WG</div>
<div class="nm">Walter de Gruyter</div>
<div class="dt">18621923</div>
</div>
<div class="dash-mini">
<div class="dh">Korrespondenz-Überblick <span>↗ Briefwechsel</span></div>
<div class="stats-m">
<div><div class="v">851</div><div class="k">gesamt</div></div>
<div><div class="v" style="color:var(--primary)">612</div><div class="k"></div></div>
<div><div class="v" style="color:var(--accent)">239</div><div class="k"></div></div>
<div><div class="v">42J</div><div class="k">Jahre</div></div>
</div>
<div class="hist-m"><i style="height:15%"></i><i style="height:30%"></i><i style="height:45%"></i><i style="height:60%"></i><i style="height:80%"></i><i class="p" style="height:100%"></i><i style="height:75%"></i><i style="height:55%"></i><i style="height:40%"></i><i style="height:25%"></i><i style="height:15%"></i><i style="height:8%"></i></div>
<div class="bars-m"><span>W. Dieckmann<span class="bar" style="flex:1"></span>184</span><span>H. Cram<span class="bar" style="flex:.78"></span>143</span><span>E. Dieckmann<span class="bar" style="flex:.48"></span>88</span></div>
<div class="cloud-m"><span>Verlag</span><span>Familie</span><span>Weihn.</span><span>Kur</span><span>Reise</span></div>
</div>
</div>
<div class="sc-body">
<h3>Person Dashboard (/persons/[id])</h3>
<p>The /briefwechsel list stays calm and reading-focused; the archive-level view — activity over years, top correspondents, top locations, tag cloud — lives on the person detail page, where it's useful <i>every time</i> you open a person, not only during letter review.</p>
<p>Every correspondent, location, tag and year on the dashboard links into <code>/briefwechsel</code> with pre-filled filters, so the dashboard is the discovery surface and /briefwechsel is the reading surface.</p>
<div class="tags"><span class="tag">Discovery</span><span class="tag">Works for bilateral too (always scoped to the person)</span><span class="tag muted">Needs aggregation endpoints</span></div>
</div>
</a>
</div>
<div class="cmp">
<table>
<thead><tr>
<th>Concept</th><th>Best for user who wants to…</th><th>Visual change</th><th>New backend</th><th>Effort</th><th>Mobile</th>
</tr></thead>
<tbody>
<tr><td>1 · Rich Rows</td><td>Scan quickly, see summary + tags on every letter</td><td>Row height 2× current</td><td>None</td><td class="yes">Small</td><td class="yes">Right column collapses</td></tr>
<tr><td>2 · Thumbnail Rows</td><td>Recognise letters visually, pick up where they left off</td><td>Thumbnail on the left</td><td>PDF thumbnail service (open issue)</td><td class="mid">Medium</td><td class="yes">Fine</td></tr>
<tr><td>3 · Master-Detail</td><td>Flip through letters in a reading session</td><td>Two-column split</td><td>None mandatory</td><td class="mid">Medium</td><td class="no">Drawer / sheet needed</td></tr>
<tr><td>4 · Gallery Cards</td><td>Browse the collection as an album</td><td>Full structural change (list → grid)</td><td>PDF thumbnail service</td><td class="no">Large</td><td class="yes">Grid reflows 4 → 2</td></tr>
<tr><td>5 · Person Dashboard</td><td>Understand a person's correspondence at a glance</td><td>New section on /persons/[id]</td><td>Aggregation endpoints (per-year, per-correspondent, per-location, per-tag)</td><td class="mid">Medium</td><td class="yes">Stacks naturally</td></tr>
</tbody>
</table>
</div>
<div style="margin-top:28px;padding:16px 20px;background:#fff;border-left:4px solid var(--brand-navy);font-size:13px;color:#333;line-height:1.65">
<b style="color:var(--brand-navy)">Proposed path forward:</b>
<ol style="margin:8px 0 0 20px;padding:0">
<li><b>Ship Spec 1 (Rich Rows)</b> now — it uses data we already have and tests whether "empty rows" is really the problem, or whether it's "not enough context to decide which letter to open".</li>
<li><b>Build Spec 5 (Person Dashboard)</b> next — it's independent of /briefwechsel and turns the person page into a real archive overview.</li>
<li><b>Upgrade to Spec 2 (Thumbnail Rows)</b> once the thumbnail service lands — it layers cleanly on top of Spec 1 without throwing work away.</li>
</ol>
<div style="margin-top:10px">Specs 3 and 4 remain on the table but are bigger re-architectures — revisit after watching how users behave with 1 + 5 + 2.</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,996 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Bulk Upload — 3 Concept Designs · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;1,300&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"/>
<style>
/* ── Reset ── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
/* ── Masthead ── */
.mh{padding-bottom:24px;border-bottom:3px solid #002850;margin-bottom:60px}
.mh .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
.mh h1{font-size:28px;font-weight:900;color:#002850;letter-spacing:-.4px;margin-top:6px}
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:10px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:14px}
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.tag{background:#002850;color:#A6DAD8;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tag.amber{background:#7c4a00;color:#fde68a}
.tag.green{background:#1e5e34;color:#d1fae5}
.tag.gray{background:#4b5563;color:#e5e7eb}
.tag.mint{background:#A6DAD8;color:#002850}
/* ── Goals card ── */
.goals{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:22px 26px;margin:0 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.goals h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:14px}
.goals ul{list-style:none;display:grid;grid-template-columns:1fr 1fr;gap:10px 28px}
.goals li{font-size:12.5px;color:#333;padding-left:20px;position:relative;line-height:1.55}
.goals li::before{content:"→";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
/* ── Concept section ── */
.concept{margin-bottom:88px;padding-bottom:88px;border-bottom:2px dashed #C8C4BE}
.concept:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
.concept-header{display:flex;align-items:flex-start;gap:24px;margin-bottom:36px}
.concept-num{font-size:84px;font-weight:900;color:#E0DDD6;line-height:1;flex-shrink:0;width:96px}
.concept-label{font-size:8.5px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#A6DAD8;margin-bottom:5px}
.concept-title{font-family:'Merriweather',Georgia,serif;font-size:24px;font-weight:700;color:#002850;margin-bottom:10px}
.concept-desc{font-size:13.5px;color:#555;max-width:740px;line-height:1.75}
.concept-best{margin-top:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.best-label{background:#A6DAD8;color:#002850;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
.best-text{font-size:12px;font-weight:600;color:#444}
.concept-tradeoff{margin-top:8px;font-size:12px;color:#888;font-style:italic;max-width:680px;line-height:1.7}
/* ── Browser chrome ── */
.screen{max-width:980px;margin:0 auto}
.screen.narrow{max-width:400px}
.chrome{background:#F5F4EE;border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
.chrome-bar{height:22px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 9px;gap:5px;flex-shrink:0}
.chrome-dot{width:7px;height:7px;border-radius:50%;background:#BDB8B1}
.chrome-url{flex:1;height:10px;background:#CCC8C2;border-radius:5px;margin-left:8px}
.viewport-hint{font-size:7.5px;font-weight:800;color:#A6DAD8;letter-spacing:1px;text-transform:uppercase;padding:4px 9px;background:#002850;border-radius:2px;margin-left:8px}
/* ── App nav ── */
.app-nav{height:32px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:12px;flex-shrink:0}
.app-logo{font-family:'Merriweather',Georgia,serif;font-size:8px;font-weight:700;color:#fff;border-bottom:2px solid #A6DAD8;padding-bottom:1px}
.app-link{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.45);white-space:nowrap}
.app-link.on{color:rgba(255,255,255,.9)}
.app-nav-r{margin-left:auto;display:flex;gap:8px;align-items:center}
.app-avatar{width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.5)}
/* ── Common form element styles ── */
.f-label{font-size:6.5px;font-weight:700;color:#666;letter-spacing:.2px;text-transform:uppercase}
.f-req{color:#C0392B}
.f-input{height:20px;border:1px solid #D4D0CA;border-radius:2px;background:#fff;font-size:7.5px;padding:0 7px;color:#333;display:flex;align-items:center}
.f-input.focus{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.12)}
.f-input.filled{color:#002850;font-weight:600;background:#FAFBFF}
.f-input.suggested{border-color:#A6DAD8;background:#F0FAFA;color:#005858;font-weight:600}
.f-input.empty{color:#BBB;font-style:italic}
.f-input.tall{height:28px}
.f-tags{display:flex;gap:3px;flex-wrap:wrap;min-height:20px;border:1px solid #D4D0CA;border-radius:2px;padding:2px 4px;background:#fff;align-items:center}
.f-chip{background:#002850;color:#A6DAD8;border-radius:2px;font-size:6px;font-weight:700;padding:1px 4px 1px 5px;display:flex;align-items:center;gap:2px}
.f-chip-rm{color:rgba(166,218,216,.5);font-weight:400}
/* ── Action bar ── */
.action-bar{height:46px;background:#F5F4EE;border-top:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px;flex-shrink:0}
.btn-skip{font-size:7px;font-weight:700;color:#AAA;letter-spacing:.2px;cursor:pointer}
.btn-spacer{flex:1}
.btn-outline{height:24px;padding:0 12px;border:1px solid #C0BDB6;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#777;display:flex;align-items:center;cursor:pointer;background:#fff}
.btn-primary{height:24px;padding:0 12px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;background:#002850;color:#fff;display:flex;align-items:center;cursor:pointer;gap:4px}
.btn-primary.green{background:#1A7040}
/* ─────────────────────────────────────── */
/* ── CONCEPT A — Stack (mobile-first) ── */
/* ─────────────────────────────────────── */
.ca-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:8px}
.ca-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
.ca-title{flex:1;text-align:center;font-family:'Merriweather',Georgia,serif;font-size:9px;color:#002850;font-weight:600}
.ca-count{font-size:7px;font-weight:700;color:#002850;background:#A6DAD8;padding:2px 6px;border-radius:10px;letter-spacing:.3px}
.ca-body{background:#ECEAE4;padding:14px 12px;overflow-y:auto}
.ca-drop{background:#fff;border:2px dashed #A6DAD8;border-radius:4px;padding:14px;text-align:center;margin-bottom:14px}
.ca-drop-icon{font-size:18px;color:#A6DAD8;margin-bottom:4px}
.ca-drop-title{font-size:8.5px;font-weight:700;color:#002850;margin-bottom:2px}
.ca-drop-sub{font-size:6.5px;color:#999}
.ca-shared-card{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.03)}
.ca-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:11px}
.ca-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.ca-shared-title{font-family:'Merriweather',Georgia,serif;font-size:9.5px;color:#002850;font-weight:700}
.ca-shared-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 10px}
.ca-shared-grid .full{grid-column:1/-1}
.ca-shared-field{display:flex;flex-direction:column;gap:3px}
.ca-files-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 2px}
.ca-files-title{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6}
.ca-files-add{font-size:7px;font-weight:700;color:#002850;display:flex;align-items:center;gap:3px}
.ca-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:9px 10px;margin-bottom:7px;display:flex;align-items:center;gap:10px}
.ca-file.active{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.08)}
.ca-thumb{width:28px;height:36px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;flex-shrink:0;display:flex;flex-direction:column;padding:3px;gap:1px}
.ca-thumb .tl{height:2px;background:#C4BDB0;opacity:.6;border-radius:1px}
.ca-thumb .tl.s{width:60%;opacity:.35}
.ca-thumb .tl.m{width:82%}
.ca-file-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.ca-file-title{font-size:8px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ca-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
.ca-file-meta{font-size:6.5px;color:#AAA}
.ca-file-rm{font-size:10px;color:#B0ADA6;padding:0 4px;cursor:pointer}
/* ───────────────────────────────────────────── */
/* ── CONCEPT B — Split-panel + file switcher ── */
/* ───────────────────────────────────────────── */
.cb-top-bar{height:38px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:10px}
.cb-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
.cb-title{font-family:'Merriweather',Georgia,serif;font-size:9px;font-weight:700;color:#002850}
.cb-count{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:10px;font-size:7px;font-weight:800;letter-spacing:.3px}
.cb-discard{margin-left:auto;font-size:7px;font-weight:700;color:#C0392B;letter-spacing:.2px}
.cb-split{display:flex;min-height:440px}
.cb-pdf{flex:55;background:#5E5C59;display:flex;flex-direction:column;border-right:1px solid #3A3836}
.cb-pdf-toolbar{height:28px;background:#3A3836;display:flex;align-items:center;padding:0 10px;gap:8px}
.cb-pdf-btn{width:16px;height:16px;border-radius:2px;background:rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;font-size:7px;color:rgba(255,255,255,.6)}
.cb-pdf-page{font-size:6.5px;color:rgba(255,255,255,.4);margin-left:auto;font-weight:700;letter-spacing:.5px}
.cb-pdf-view{flex:1;display:flex;justify-content:center;padding:14px;overflow:hidden}
.cb-paper{background:#FFFEF8;box-shadow:0 2px 10px rgba(0,0,0,.3);border-radius:1px;padding:14px 16px;display:flex;flex-direction:column;gap:0;width:180px;flex-shrink:0}
.pl{height:4px;background:#C4BDB0;border-radius:1px;opacity:.55;margin-bottom:3px}
.pl.h{height:6px;opacity:.75;margin-bottom:5px}
.pl.s{width:55%;opacity:.3}
.pl.m{width:80%}
.pl.sp{height:7px;background:transparent}
.cb-filebar{background:#434140;border-top:1px solid #3A3836;display:flex;align-items:center;padding:0 8px;gap:3px;height:36px;flex-shrink:0}
.cb-fb-arrow{width:18px;height:22px;border-radius:2px;background:rgba(255,255,255,.08);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,.6)}
.cb-fb-track{flex:1;display:flex;gap:3px;padding:0 3px;overflow:hidden}
.cb-fb-item{padding:3px 6px;border-radius:2px;font-size:6px;font-weight:700;color:rgba(255,255,255,.55);background:rgba(255,255,255,.06);display:flex;align-items:center;gap:4px;white-space:nowrap}
.cb-fb-item.on{background:#A6DAD8;color:#002850}
.cb-fb-num{background:rgba(0,0,0,.15);border-radius:2px;padding:0 3px;font-size:5.5px;font-weight:800}
.cb-fb-item.on .cb-fb-num{background:rgba(0,40,80,.25);color:#002850}
.cb-form{flex:45;background:#fff;display:flex;flex-direction:column}
.cb-form-scroll{flex:1;overflow-y:auto;padding:14px}
.cb-only-card{background:#F0FAFA;border:1px solid #A6DAD8;border-radius:3px;padding:10px 12px;margin-bottom:12px}
.cb-only-head{display:flex;align-items:center;gap:6px;margin-bottom:7px}
.cb-only-badge{background:#005858;color:#A6DAD8;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.cb-only-subtitle{font-size:6.5px;color:#005858;font-weight:600;letter-spacing:.3px}
.cb-shared-card{background:#F9F8F5;border:1px solid #E4E2D7;border-radius:3px;padding:10px 12px;margin-bottom:10px}
.cb-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:9px}
.cb-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.cb-shared-subtitle{font-size:6.5px;color:#002850;font-weight:600}
.cb-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:7px}
.cb-row.full{grid-template-columns:1fr}
.cb-field{display:flex;flex-direction:column;gap:3px}
/* ─────────────────────────────────────── */
/* ── CONCEPT C — Progressive accordion ── */
/* ─────────────────────────────────────── */
.cc-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px}
.cc-body{background:#ECEAE4;padding:14px;display:flex;flex-direction:column;gap:11px;max-height:540px;overflow-y:auto}
.cc-shared{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.03);position:sticky;top:0;z-index:2}
.cc-shared-head{display:flex;align-items:center;gap:7px;margin-bottom:11px}
.cc-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.cc-shared-title{font-family:'Merriweather',Georgia,serif;font-size:10px;color:#002850;font-weight:700}
.cc-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px 10px}
.cc-grid .span2{grid-column:span 2}
.cc-files-label{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6;padding:0 2px;margin-top:6px}
.cc-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;overflow:hidden}
.cc-file.open{border-color:#002850;box-shadow:0 2px 6px rgba(0,40,80,.08)}
.cc-file-head{display:flex;align-items:center;gap:10px;padding:9px 12px;cursor:pointer}
.cc-file-head.open{border-bottom:1px solid #E4E2D7;background:#F9F8F5}
.cc-caret{font-size:9px;color:#A6DAD8;width:10px}
.cc-file-thumb{width:22px;height:28px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;padding:2px;display:flex;flex-direction:column;gap:1px;flex-shrink:0}
.cc-file-thumb .tl{height:2px;background:#C4BDB0;opacity:.55;border-radius:1px}
.cc-file-body{flex:1;min-width:0}
.cc-file-titlerow{display:flex;align-items:center;gap:7px}
.cc-file-title{font-size:8.5px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cc-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
.cc-file-meta{font-size:6.5px;color:#AAA;margin-top:2px}
.cc-file-rm{font-size:11px;color:#B0ADA6;padding:0 4px}
.cc-file-open{display:flex;background:#F5F4EE}
.cc-preview{flex:45;background:#5E5C59;padding:12px;display:flex;justify-content:center}
.cc-preview-paper{background:#FFFEF8;border-radius:1px;padding:8px 10px;width:110px;flex-shrink:0;display:flex;flex-direction:column;box-shadow:0 2px 6px rgba(0,0,0,.25)}
.cc-file-form{flex:55;padding:12px 14px;background:#fff;display:flex;flex-direction:column;gap:7px}
/* ─────────── Decision matrix ─────────── */
.decision{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;margin:88px 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.decision h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:6px}
.decision p.lead{font-size:13.5px;color:#555;line-height:1.7;margin-bottom:22px;max-width:820px}
.dm{width:100%;border-collapse:collapse;margin-top:12px;font-size:12px}
.dm th{text-align:left;font-size:9.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#002850;padding:9px 12px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
.dm td{padding:13px 12px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.6}
.dm td:first-child{font-weight:700;color:#002850;width:18%;white-space:nowrap}
.dm td.score{font-size:15px;text-align:center;width:12%}
.dm td.ok{color:#1A7040}
.dm td.mid{color:#A07100}
.dm td.bad{color:#C0392B}
/* ─────────── Recommendation ─────────── */
.reco{background:#002850;color:#fff;border-radius:6px;padding:36px 40px;margin:48px 0 64px;box-shadow:0 4px 20px rgba(0,40,80,.15)}
.reco .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
.reco h2{font-family:'Merriweather',Georgia,serif;font-size:26px;font-weight:700;margin-top:6px}
.reco .why{font-size:13.5px;line-height:1.85;color:rgba(255,255,255,.88);max-width:780px;margin-top:14px}
.reco ul{list-style:none;margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:9px 26px}
.reco ul li{font-size:12.5px;color:rgba(255,255,255,.9);padding-left:22px;position:relative;line-height:1.6}
.reco ul li::before{content:"✓";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
/* ─────────── Impl-ref ─────────── */
.impl{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.impl h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:16px}
.impl h3{font-family:'Merriweather',Georgia,serif;font-size:15px;color:#002850;margin:22px 0 10px}
.impl-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:12px}
.impl-table th{text-align:left;font-size:9px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:#002850;padding:8px 10px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
.impl-table td{padding:10px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.55}
.impl-table td:first-child{font-weight:700;color:#002850;width:22%}
.impl-table td code{font-family:'SF Mono','Menlo',monospace;font-size:11px;background:#F0EEE8;padding:1px 6px;border-radius:2px;color:#002850}
.impl-table td.px{color:#777;font-size:11.5px;width:16%}
.impl-table td.note{color:#888;font-size:11.5px;font-style:italic;width:22%}
.impl h3.ix{margin-top:32px}
.notes{background:#F9F8F5;border-left:3px solid #A6DAD8;padding:16px 22px;border-radius:0 4px 4px 0;margin-top:26px}
.notes .nh{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#002850;margin-bottom:8px}
.notes ul{list-style:none;display:flex;flex-direction:column;gap:6px}
.notes li{font-size:12px;color:#333;padding-left:18px;position:relative;line-height:1.7}
.notes li::before{content:"•";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
</style>
</head>
<body>
<div class="doc">
<!-- ════════════════════════════════════════════ -->
<!-- ═══════════════ MASTHEAD ══════════════ -->
<!-- ════════════════════════════════════════════ -->
<div class="mh">
<div class="kicker">UX Spec · Bulk Upload</div>
<h1>Uploading multiple documents in a single pass</h1>
<p>
Extends issue <strong>#294</strong> (new-document split-panel) with bulk uploads. When a user drops
N&nbsp;files, every metadata field applies once to all of them — only the <em>title</em> is per-file,
pre-filled from the filename and editable inline. A single save POST creates N documents.
</p>
<div class="byline">Prepared by Leonie Voss · 2026-04-24 · Draft 1 · References: #294, #305</div>
<div class="tag-row">
<span class="tag">feature</span>
<span class="tag mint">ui</span>
<span class="tag gray">a11y 320px+</span>
<span class="tag green">backend ready</span>
</div>
</div>
<!-- Goals -->
<div class="goals">
<h2>Design goals</h2>
<ul>
<li><strong>One-pass feel</strong>: drop → fill shared fields → save. No wizard, no per-file detour.</li>
<li><strong>Every field is shared except the title</strong>, which is always set (filename-derived).</li>
<li><strong>No mode switch</strong>: 1 file and N files use the same screen — more files reveal more chrome.</li>
<li><strong>Scales to 20+ files</strong> without the form losing scan-ability on mobile.</li>
<li><strong>Reuses the #294 split-panel layout</strong> (DocumentEditLayout) — minimum new surface.</li>
<li><strong>a11y-first</strong>: 44px targets, focus states, `aria-current` on active file, keyboard-navigable.</li>
</ul>
</div>
<!-- ════════════════════════════════════════════ -->
<!-- ═════════ CONCEPT A — STACK ═════════ -->
<!-- ════════════════════════════════════════════ -->
<section class="concept">
<div class="concept-header">
<div class="concept-num">A</div>
<div>
<div class="concept-label">Concept A</div>
<div class="concept-title">Flat Stack — shared header · file cards · sticky save</div>
<p class="concept-desc">
A single vertical flow: drop zone on top, then a <em>Gilt für alle</em> metadata card,
then stacked file cards (thumbnail · editable title · remove). No split panel, no tabs.
Scrolling down reveals all files; the save bar sticks to the bottom.
</p>
<div class="concept-best">
<span class="best-label">Best for</span>
<span class="best-text">Small-screen workflows. Seniors who prefer linear flows over tabs.</span>
</div>
<div class="concept-tradeoff">
Trade-off: no PDF preview until you click through to the document after save. Harder to verify
you grabbed the right files before committing.
</div>
</div>
</div>
<!-- mobile mockup -->
<div class="screen narrow">
<div class="chrome">
<div class="chrome-bar">
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
<div class="chrome-url"></div>
<div class="viewport-hint">375 · mobile</div>
</div>
<div class="app-nav">
<div class="app-logo">Familienarchiv</div>
<div class="app-nav-r">
<div class="app-avatar">MR</div>
</div>
</div>
<div class="ca-top-bar">
<div class="ca-back">← Zurück</div>
<div class="ca-title">Neue Dokumente</div>
<div class="ca-count">5</div>
</div>
<div class="ca-body" style="height:500px">
<!-- drop zone -->
<div class="ca-drop">
<div class="ca-drop-icon"></div>
<div class="ca-drop-title">Weitere Dateien hinzufügen</div>
<div class="ca-drop-sub">PDF, JPEG, PNG, TIFF · max 50 MB</div>
</div>
<!-- shared card -->
<div class="ca-shared-card">
<div class="ca-shared-head">
<span class="ca-shared-badge">Gilt für alle 5</span>
<span class="ca-shared-title">Angaben</span>
</div>
<div class="ca-shared-grid">
<div class="ca-shared-field">
<span class="f-label">Absender</span>
<div class="f-input filled">Hans Müller</div>
</div>
<div class="ca-shared-field">
<span class="f-label">Empfänger</span>
<div class="f-input filled">Anna Schmidt</div>
</div>
<div class="ca-shared-field">
<span class="f-label">Datum</span>
<div class="f-input filled">1950-06</div>
</div>
<div class="ca-shared-field">
<span class="f-label">Ort</span>
<div class="f-input empty">Berlin</div>
</div>
<div class="ca-shared-field full">
<span class="f-label">Tags</span>
<div class="f-tags">
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
</div>
</div>
</div>
</div>
<!-- files list -->
<div class="ca-files-head">
<div class="ca-files-title">5 Dateien · Titel bearbeiten</div>
</div>
<div class="ca-file active">
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl m"></div><div class="tl s"></div></div>
<div class="ca-file-body">
<div class="ca-file-title">Brief_1940_Hans</div>
<div class="ca-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
</div>
<div class="ca-file-rm"></div>
</div>
<div class="ca-file">
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl"></div><div class="tl s"></div></div>
<div class="ca-file-body">
<div class="ca-file-title">Brief_1940_Anna</div>
<div class="ca-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
</div>
<div class="ca-file-rm"></div>
</div>
<div class="ca-file">
<div class="ca-thumb"><div class="tl h"></div><div class="tl m"></div><div class="tl"></div><div class="tl"></div></div>
<div class="ca-file-body">
<div class="ca-file-title">Brief_1941_Clara</div>
<div class="ca-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
</div>
<div class="ca-file-rm"></div>
</div>
<div class="ca-file">
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl s"></div><div class="tl m"></div></div>
<div class="ca-file-body">
<div class="ca-file-title placeholder">Postkarte_Venedig</div>
<div class="ca-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
</div>
<div class="ca-file-rm"></div>
</div>
</div>
<div class="action-bar">
<div class="btn-skip">Alle verwerfen</div>
<div class="btn-spacer"></div>
<div class="btn-outline">Als Platzhalter</div>
<div class="btn-primary">5 speichern →</div>
</div>
</div>
</div>
</section>
<!-- ════════════════════════════════════════════ -->
<!-- ═══ CONCEPT B — SPLIT-PANEL + SWITCHER ══ -->
<!-- ════════════════════════════════════════════ -->
<section class="concept">
<div class="concept-header">
<div class="concept-num">B</div>
<div>
<div class="concept-label">Concept B · RECOMMENDED</div>
<div class="concept-title">Split-Panel with File Switcher</div>
<p class="concept-desc">
Reuses the <em>DocumentEditLayout</em> from issue #294 and adds a horizontal file-switcher strip
under the PDF preview. Right column splits into two cards: <em>Gilt nur für diese Datei</em>
(title only, mint accent) and <em>Gilt für alle N Dokumente</em> (everything else).
When N=1 the switcher disappears and the screen is byte-identical to #294.
</p>
<div class="concept-best">
<span class="best-label">Best for</span>
<span class="best-text">The project's primary use case. Desktop + tablet, matches #294 DNA.</span>
</div>
<div class="concept-tradeoff">
Trade-off: on mobile the split has to collapse into tabs ("Vorschau / Angaben"). We reuse the
same responsive pattern that DocumentEditLayout already ships with.
</div>
</div>
</div>
<!-- desktop mockup -->
<div class="screen">
<div class="chrome">
<div class="chrome-bar">
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
<div class="chrome-url"></div>
<div class="viewport-hint">1280 · desktop</div>
</div>
<div class="app-nav">
<div class="app-logo">Familienarchiv</div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-link">Briefwechsel</div>
<div class="app-link">Chronik</div>
<div class="app-nav-r">
<div class="app-avatar">MR</div>
</div>
</div>
<div class="cb-top-bar">
<div class="cb-back">← Dokumente</div>
<div class="cb-title">Neue Dokumente</div>
<div class="cb-count">5 werden erstellt</div>
<div class="cb-discard">Alle verwerfen</div>
</div>
<div class="cb-split">
<!-- PDF side -->
<div class="cb-pdf">
<div class="cb-pdf-toolbar">
<div class="cb-pdf-btn"></div>
<div class="cb-pdf-btn"></div>
<div class="cb-pdf-btn">+</div>
<div class="cb-pdf-btn"></div>
<div class="cb-pdf-page">Seite 1 / 2 · Datei 1 von 5</div>
</div>
<div class="cb-pdf-view">
<div class="cb-paper">
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
<div class="pl s"></div><div class="pl sp"></div>
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
<div class="pl"></div><div class="pl s"></div><div class="pl sp"></div>
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
</div>
</div>
<!-- file switcher -->
<div class="cb-filebar">
<div class="cb-fb-arrow"></div>
<div class="cb-fb-track">
<div class="cb-fb-item on"><span class="cb-fb-num">1</span> Brief_1940_Hans.pdf</div>
<div class="cb-fb-item"><span class="cb-fb-num">2</span> Brief_1940_Anna.pdf</div>
<div class="cb-fb-item"><span class="cb-fb-num">3</span> Brief_1941_Clara.pdf</div>
<div class="cb-fb-item"><span class="cb-fb-num">4</span> Postkarte_Venedig.jpg</div>
<div class="cb-fb-item"><span class="cb-fb-num">5</span> Urkunde_1942.pdf</div>
</div>
<div class="cb-fb-arrow"></div>
</div>
</div>
<!-- Form side -->
<div class="cb-form">
<div class="cb-form-scroll">
<!-- PER-FILE card -->
<div class="cb-only-card">
<div class="cb-only-head">
<span class="cb-only-badge">Nur diese Datei</span>
<span class="cb-only-subtitle">1 / 5 · Brief_1940_Hans.pdf</span>
</div>
<div class="cb-row full">
<div class="cb-field">
<span class="f-label">Titel <span class="f-req">*</span></span>
<div class="f-input filled tall">Brief an Anna, 1940</div>
</div>
</div>
</div>
<!-- SHARED card -->
<div class="cb-shared-card">
<div class="cb-shared-head">
<span class="cb-shared-badge">Gilt für alle 5</span>
<span class="cb-shared-subtitle">Gemeinsame Angaben</span>
</div>
<div class="cb-row">
<div class="cb-field">
<span class="f-label">Absender</span>
<div class="f-input filled">Hans Müller</div>
</div>
<div class="cb-field">
<span class="f-label">Empfänger</span>
<div class="f-input filled">Anna Schmidt</div>
</div>
</div>
<div class="cb-row">
<div class="cb-field">
<span class="f-label">Datum</span>
<div class="f-input filled">15.06.1950</div>
</div>
<div class="cb-field">
<span class="f-label">Ort</span>
<div class="f-input empty">z.B. Berlin</div>
</div>
</div>
<div class="cb-row full">
<div class="cb-field">
<span class="f-label">Tags</span>
<div class="f-tags">
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
<span class="f-chip">Briefwechsel <span class="f-chip-rm">×</span></span>
</div>
</div>
</div>
<div class="cb-row">
<div class="cb-field">
<span class="f-label">Archivbox</span>
<div class="f-input empty">z.B. B-12</div>
</div>
<div class="cb-field">
<span class="f-label">Mappe</span>
<div class="f-input empty">z.B. M-3</div>
</div>
</div>
</div>
</div>
<div class="action-bar">
<div class="btn-skip">Alle verwerfen</div>
<div class="btn-spacer"></div>
<div class="btn-outline">Als Platzhalter</div>
<div class="btn-primary green">5 speichern →</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ════════════════════════════════════════════ -->
<!-- ══ CONCEPT C — PROGRESSIVE ACCORDION ══ -->
<!-- ════════════════════════════════════════════ -->
<section class="concept">
<div class="concept-header">
<div class="concept-num">C</div>
<div>
<div class="concept-label">Concept C</div>
<div class="concept-title">Progressive Accordion — shared sticky header · file cards expand inline</div>
<p class="concept-desc">
Shared metadata sticks at the top of the page. Below, each file is a collapsed card; clicking
a card expands it to show the PDF preview + title field inline. Only one card is expanded at a
time. Scales well to 20+ files — the list stays readable, you only look at the PDFs you want
to verify.
</p>
<div class="concept-best">
<span class="best-label">Best for</span>
<span class="best-text">Large batches (10+ files) where you want to spot-check a few.</span>
</div>
<div class="concept-tradeoff">
Trade-off: two different visual languages — cards collapsed vs. cards expanded with PDF. New
pattern for the project; costs familiarity.
</div>
</div>
</div>
<div class="screen">
<div class="chrome">
<div class="chrome-bar">
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
<div class="chrome-url"></div>
<div class="viewport-hint">1280 · desktop</div>
</div>
<div class="app-nav">
<div class="app-logo">Familienarchiv</div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-nav-r"><div class="app-avatar">MR</div></div>
</div>
<div class="cc-top-bar">
<div class="ca-back">← Zurück</div>
<div class="ca-title">Neue Dokumente</div>
<div class="ca-count">5</div>
</div>
<div class="cc-body">
<!-- sticky shared card -->
<div class="cc-shared">
<div class="cc-shared-head">
<span class="cc-shared-badge">Gilt für alle 5</span>
<span class="cc-shared-title">Gemeinsame Angaben</span>
</div>
<div class="cc-grid">
<div class="cb-field"><span class="f-label">Absender</span><div class="f-input filled">Hans Müller</div></div>
<div class="cb-field"><span class="f-label">Empfänger</span><div class="f-input filled">Anna Schmidt</div></div>
<div class="cb-field"><span class="f-label">Datum</span><div class="f-input filled">15.06.1950</div></div>
<div class="cb-field span2"><span class="f-label">Tags</span><div class="f-tags"><span class="f-chip">Familie <span class="f-chip-rm">×</span></span><span class="f-chip">Krieg <span class="f-chip-rm">×</span></span></div></div>
<div class="cb-field"><span class="f-label">Ort</span><div class="f-input empty">z.B. Berlin</div></div>
</div>
</div>
<div class="cc-files-label">5 Dateien</div>
<!-- collapsed card -->
<div class="cc-file">
<div class="cc-file-head">
<div class="cc-caret"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title">Brief an Anna, 1940</div>
</div>
<div class="cc-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
</div>
<div class="cc-file-rm"></div>
</div>
</div>
<!-- expanded card -->
<div class="cc-file open">
<div class="cc-file-head open">
<div class="cc-caret" style="color:#002850"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title">Brief von Anna, Antwort</div>
</div>
<div class="cc-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
</div>
<div class="cc-file-rm"></div>
</div>
<div class="cc-file-open">
<div class="cc-preview">
<div class="cc-preview-paper">
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
<div class="pl sp"></div>
<div class="pl"></div><div class="pl"></div><div class="pl m"></div>
</div>
</div>
<div class="cc-file-form">
<div class="cb-only-head">
<span class="cb-only-badge">Nur diese Datei</span>
<span class="cb-only-subtitle">2 / 5</span>
</div>
<div class="cb-field">
<span class="f-label">Titel <span class="f-req">*</span></span>
<div class="f-input filled tall">Brief von Anna, Antwort</div>
</div>
</div>
</div>
</div>
<!-- more collapsed -->
<div class="cc-file">
<div class="cc-file-head">
<div class="cc-caret"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title placeholder">Brief_1941_Clara</div>
</div>
<div class="cc-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
</div>
<div class="cc-file-rm"></div>
</div>
</div>
<div class="cc-file">
<div class="cc-file-head">
<div class="cc-caret"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title placeholder">Postkarte_Venedig</div>
</div>
<div class="cc-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
</div>
<div class="cc-file-rm"></div>
</div>
</div>
<div class="cc-file">
<div class="cc-file-head">
<div class="cc-caret"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title placeholder">Urkunde_1942</div>
</div>
<div class="cc-file-meta">Urkunde_1942.pdf · 3.1 MB</div>
</div>
<div class="cc-file-rm"></div>
</div>
</div>
</div>
<div class="action-bar">
<div class="btn-skip">Alle verwerfen</div>
<div class="btn-spacer"></div>
<div class="btn-outline">Als Platzhalter</div>
<div class="btn-primary green">5 speichern →</div>
</div>
</div>
</div>
</section>
<!-- ════════════════════════════════════════════ -->
<!-- ══════════ DECISION MATRIX ════════════ -->
<!-- ════════════════════════════════════════════ -->
<div class="decision">
<h2>Decision matrix</h2>
<p class="lead">
All three concepts meet the core requirement (shared metadata + per-file title + one save).
Graded against what matters for the senior audience, the responsive constraint, and the #294
architectural commitment.
</p>
<table class="dm">
<thead>
<tr>
<th>Dimension</th>
<th>A · Stack</th>
<th>B · Split-Panel</th>
<th>C · Accordion</th>
</tr>
</thead>
<tbody>
<tr>
<td>Reuses #294 layout</td>
<td class="score bad"></td>
<td class="score ok"></td>
<td class="score bad"></td>
</tr>
<tr>
<td>Single-file mode unchanged</td>
<td class="score mid">rewrite</td>
<td class="score ok">identical</td>
<td class="score bad">different</td>
</tr>
<tr>
<td>PDF visible before save</td>
<td class="score bad">no</td>
<td class="score ok">always</td>
<td class="score mid">one at a time</td>
</tr>
<tr>
<td>Works at 320px</td>
<td class="score ok">native</td>
<td class="score mid">via tab collapse</td>
<td class="score ok">native</td>
</tr>
<tr>
<td>Scales to 20 files</td>
<td class="score mid">long scroll</td>
<td class="score ok">switcher scrolls</td>
<td class="score ok">collapsed list</td>
</tr>
<tr>
<td>New Svelte components</td>
<td class="score bad">3 new</td>
<td class="score ok">1 new (switcher)</td>
<td class="score bad">4 new</td>
</tr>
<tr>
<td>Familiar pattern</td>
<td class="score ok">yes</td>
<td class="score ok">yes (post-#294)</td>
<td class="score mid">new to app</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════════ -->
<!-- ══════════ RECOMMENDATION ════════════ -->
<!-- ════════════════════════════════════════════ -->
<div class="reco">
<div class="kicker">Recommendation</div>
<h2>Ship Concept B</h2>
<p class="why">
Concept&nbsp;B treats bulk upload as a <em>polymorphic state</em> of the existing single-document
layout rather than a separate screen. A user who drops one file gets exactly the #294 experience.
A user who drops five gets the same screen plus a horizontal file-switcher and a two-card split
(<em>Nur diese Datei</em> vs. <em>Gilt für alle</em>). Nothing about the single-file flow changes.
</p>
<ul>
<li>Keeps the mental model: "one form, one save" regardless of file count.</li>
<li>PDF preview is persistent — you can spot-check each scan before committing.</li>
<li>The per-file title is visually promoted with a mint border so it reads as the one thing that differs per file.</li>
<li>Reuses DocumentEditLayout: the delta is ~1 new component (<code>FileSwitcherStrip</code>) + two cards in the form.</li>
<li>Single-file mode is byte-identical to #294 — no regression risk for existing users.</li>
<li>Backend is already ready (<code>POST /api/documents/quick-upload</code> accepts N files in one multipart).</li>
</ul>
</div>
<!-- ════════════════════════════════════════════ -->
<!-- ══════════ IMPL-REF · CONCEPT B ═══════ -->
<!-- ════════════════════════════════════════════ -->
<div class="impl">
<h2>Implementation reference — Concept B</h2>
<h3>Top bar (when N > 1)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Count pill "N werden erstellt"</td>
<td><code>bg-accent text-primary rounded-full px-3 py-1 text-sm font-bold</code></td>
<td class="px">14px · 700</td>
<td class="note">brand-mint on brand-navy</td>
</tr>
<tr>
<td>"Alle verwerfen" link</td>
<td><code>ml-auto text-sm font-bold text-red-600 hover:text-red-800 focus-visible:outline-2 focus-visible:outline-red-600</code></td>
<td class="px">14px / 44px target</td>
<td class="note">confirm dialog before wiping</td>
</tr>
</table>
<h3 class="ix">FileSwitcherStrip (new component)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Strip container</td>
<td><code>flex items-center gap-1 bg-ink/95 px-2 py-2 border-t border-ink/80</code></td>
<td class="px">height 48px</td>
<td class="note">under the PDF toolbar, on the dark panel</td>
</tr>
<tr>
<td>Arrow buttons</td>
<td><code>h-10 w-10 rounded-sm bg-white/8 text-surface/60 hover:bg-white/15 focus-visible:outline-2</code></td>
<td class="px">40×40 (44 w/padding)</td>
<td class="note"><code>aria-label="Vorherige Datei"</code> / "Nächste Datei"</td>
</tr>
<tr>
<td>File chip (inactive)</td>
<td><code>px-3 py-2 rounded-sm bg-white/6 text-sm font-bold text-surface/55 whitespace-nowrap hover:bg-white/12</code></td>
<td class="px">14px / h 40px</td>
<td class="note">horizontal scroll container uses <code>snap-x snap-mandatory</code></td>
</tr>
<tr>
<td>File chip (active)</td>
<td><code>... bg-accent text-primary</code> + <code>aria-current="true"</code></td>
<td class="px">14px / h 40px</td>
<td class="note">mint pill, primary text — 7.2:1 contrast passes AAA</td>
</tr>
<tr>
<td>Chip number prefix</td>
<td><code>bg-primary/25 rounded-sm px-1 mr-2 text-xs font-extrabold</code></td>
<td class="px">12px / 800</td>
<td class="note">"1", "2", … — for quick scanning</td>
</tr>
</table>
<h3 class="ix">"Nur diese Datei" card (per-file scope)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Card container</td>
<td><code>bg-accent/20 border border-accent rounded-sm p-4 mb-4</code></td>
<td class="px">padding 16px</td>
<td class="note">mint tint signals "different per file"</td>
</tr>
<tr>
<td>Scope badge</td>
<td><code>bg-primary/90 text-accent rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
<td class="px">12px · 800</td>
<td class="note">Paraglide key: <code>bulk_only_this_file</code></td>
</tr>
<tr>
<td>Title input</td>
<td><code>h-11 text-base font-semibold text-ink bg-white border border-line rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20</code></td>
<td class="px">44px min-height · 16px</td>
<td class="note">pre-filled from filename <em>without extension</em></td>
</tr>
</table>
<h3 class="ix">"Gilt für alle" card (shared scope)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Card container</td>
<td><code>bg-surface border border-line rounded-sm p-4 mb-3</code></td>
<td class="px">padding 16px</td>
<td class="note">neutral (no accent tint)</td>
</tr>
<tr>
<td>Scope badge</td>
<td><code>bg-accent text-primary rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
<td class="px">12px · 800</td>
<td class="note">Paraglide: <code>bulk_shared_count</code> ("Gilt für alle {count}")</td>
</tr>
<tr>
<td>Field grid</td>
<td><code>grid grid-cols-1 md:grid-cols-2 gap-3</code></td>
<td class="px">12px gap</td>
<td class="note">single column at 320px, two at ≥ 768px</td>
</tr>
</table>
<h3 class="ix">Save bar</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Primary save button</td>
<td><code>h-11 px-5 bg-green-700 hover:bg-green-800 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900</code></td>
<td class="px">44px min · 14px</td>
<td class="note">label <code>{count} speichern →</code> (plural-aware Paraglide)</td>
</tr>
<tr>
<td>"Als Platzhalter" (outline)</td>
<td><code>h-11 px-4 border border-line bg-white text-ink-3 font-bold rounded-sm text-sm</code></td>
<td class="px">44px</td>
<td class="note">posts with <code>metadataComplete=false</code> for all</td>
</tr>
</table>
<h3 class="ix">Responsive collapse (≤ 767px)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Panel mode switch</td>
<td>reuses DocumentEditLayout's existing tab collapse — "Vorschau / Angaben" tabs</td>
<td class="px">tab height 48px</td>
<td class="note">already shipped with #294</td>
</tr>
<tr>
<td>File switcher stays on "Vorschau" tab</td>
<td><code>snap-x snap-mandatory overflow-x-auto</code></td>
<td class="px">h 44px</td>
<td class="note">horizontal swipe; arrow buttons removed at mobile</td>
</tr>
</table>
<div class="notes">
<div class="nh">Interactions + behaviour</div>
<ul>
<li><strong>Drop a file after the initial batch</strong>: append to the end of the list and switch focus to the newly added file. No modal, no confirmation.</li>
<li><strong>Remove a file</strong> (X on the chip) → confirm only if it's the currently-previewed one; otherwise silent. When count drops to 1 the switcher strip animates away (200ms); when it drops to 0 we redirect back to the drop-zone state.</li>
<li><strong>Title auto-fill</strong>: <code>filename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim()</code>. Marks the title input as <code>suggested</code> until the user edits it (mint left border, same treatment as #294's filename-derived fields).</li>
<li><strong>Title field visibility</strong>: always rendered (never collapsed) even in single-file mode, so there's zero layout jump when N changes from 1 to 2.</li>
<li><strong>Save flow</strong>: single POST to <code>/api/documents/quick-upload</code> with N files + JSON metadata object containing shared fields + titles array. Backend maps title[i] to files[i] by index. Response splits into <code>created[] / updated[] / errors[]</code> — show a summary toast + inline error markers per file for the <code>errors[]</code> list.</li>
<li><strong>Keyboard navigation</strong>: <kbd></kbd>/<kbd></kbd> on the switcher strip moves file focus; <kbd>Tab</kbd> cycles through form fields inside whichever card is active; <kbd>Esc</kbd> on the discard button opens the confirm dialog.</li>
<li><strong>Focus management on file switch</strong>: when the user clicks a different file, the title input of the new file receives focus automatically (so the main editable field is always reachable).</li>
<li><strong>Progress indicator during save</strong>: replace the save button with a determinate progress bar showing "Lade Datei 3 von 5…" for batches that take > 500ms.</li>
</ul>
</div>
<div class="notes" style="margin-top:14px;border-left-color:#C0392B">
<div class="nh" style="color:#C0392B">Edge cases + a11y</div>
<ul>
<li><strong>Duplicate filenames in the batch</strong>: accept, but show a warning icon next to both — backend will create both with unique IDs.</li>
<li><strong>Mixed content types</strong>: PDF + image in the same batch is fine; the preview panel renders whichever the active file is (DocumentEditLayout already handles both).</li>
<li><strong>Large batches (> 20 files)</strong>: the switcher strip becomes scrollable; consider a "Jump to file…" combobox at > 30 files (out of scope for v1).</li>
<li><strong>Upload failure per file</strong>: mark the chip red (<code>bg-red-600/20 text-red-800 border border-red-600</code>), show inline error in the chip's tooltip, don't block the rest of the batch from retrying.</li>
<li><strong>Screen reader announcement</strong>: when file count changes, fire a polite live region announce — "5 Dateien bereit zum Speichern" via <code>role="status" aria-live="polite"</code>.</li>
<li><strong>Colour-alone warning</strong>: active file chip uses color + <code>aria-current="true"</code> + a ▸ caret prefix so it's distinguishable for color-blind users.</li>
</ul>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

2043
docs/specs/chronik-spec.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,577 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Ergänzungs-Liste — Dashboard-Block · Spec (Issue t.b.d.)</title>
<style>
:root{
--navy:#002850;--mint:#A6DAD8;--sand:#E4E2D7;
--surface:#FAFAF7;--bg:#E8E7E2;--border:#D8D7D0;
--text:#1C1C18;--muted:#6B6A63;--subtle:#9B9A93;
--orange:#C26A00;--orange-bg:#FEF4E2;
--green:#2E6E39;--green-bg:#EAF5EA;
--red:#A83232;--red-bg:#FCEBEB;
--font:system-ui,sans-serif;--mono:'Courier New',monospace;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;}
.doc{max-width:1100px;margin:0 auto;padding:48px 32px 96px;}
hr{border:none;border-top:1px solid var(--border);margin:48px 0;}
/* Header */
.hdr{background:var(--navy);color:#fff;padding:32px 32px 28px;border-radius:8px 8px 0 0;}
.hdr h1{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;}
.hdr-meta{font-family:var(--mono);font-size:11px;color:rgba(255,255,255,.45);margin-top:10px;}
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.05em;background:var(--mint);color:var(--navy);}
.badge-g{background:rgba(255,255,255,.15);color:rgba(255,255,255,.9);}
.badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
.decision-box{background:#fff;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:20px 28px 24px;margin-bottom:40px;}
.decision-box h2{font-family:Georgia,serif;font-size:16px;font-weight:400;color:var(--navy);margin-bottom:8px;}
.prose{font-size:13px;color:var(--muted);line-height:1.65;max-width:720px;margin-bottom:10px;}
.prose:last-child{margin-bottom:0;}
.prose strong{color:var(--text);}
/* Sections */
.sec{margin-bottom:52px;}
.sec-label{font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:22px;}
.sec-title{font-family:Georgia,serif;font-size:20px;font-weight:400;color:var(--navy);margin-bottom:4px;}
.sec-sub{font-size:13px;color:var(--muted);margin-bottom:20px;max-width:740px;}
/* Callout */
.callout{display:flex;gap:12px;padding:14px 16px;border-radius:4px;margin-bottom:16px;font-size:12px;line-height:1.55;}
.callout.orange{background:var(--orange-bg);border-left:3px solid var(--orange);}
.callout.green{background:var(--green-bg);border-left:3px solid var(--green);}
.callout.navy{background:rgba(0,40,80,.05);border-left:3px solid var(--navy);}
.callout.red{background:var(--red-bg);border-left:3px solid var(--red);}
.callout strong{font-weight:700;}
/* Mock frame wrappers — scaled visual mockups (~55% of real) */
.frames-row{display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start;margin-bottom:20px;}
.frame-caption{font-family:var(--mono);font-size:10px;color:var(--muted);display:block;margin-top:8px;max-width:100%;}
/* ========== MOBILE MOCK (176px wide = 320 × .55) ========== */
.m-phone{width:176px;background:#fff;border:6px solid #2A2A2A;border-radius:22px;overflow:hidden;box-shadow:0 6px 20px rgba(0,0,0,.15);}
.m-status{height:11px;background:#000;}
.m-body{background:var(--surface);padding:7px;}
/* Eyebrow */
.mb-eyebrow{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;padding:0 2px;}
.mb-eyebrow-l{font-size:6px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
.mb-eyebrow-count{background:var(--navy);color:#fff;font-size:6px;font-weight:700;padding:1px 5px;border-radius:8px;}
.mb-block{background:#fff;border:1px solid var(--sand);border-radius:3px;overflow:hidden;}
.mb-row{display:flex;align-items:center;padding:6px 7px;gap:5px;border-bottom:1px solid var(--sand);}
.mb-row:last-child{border-bottom:none;}
.mb-icon{width:14px;height:14px;background:rgba(0,40,80,.06);border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;color:var(--navy);flex-shrink:0;}
.mb-icon.pdf{background:rgba(168,50,50,.12);color:var(--red);}
.mb-icon.jpg{background:rgba(46,110,57,.12);color:var(--green);}
.mb-main{flex:1;min-width:0;}
.mb-title{font-family:Georgia,serif;font-size:7px;color:var(--navy);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.mb-meta{font-size:5.5px;color:var(--muted);margin-top:1px;}
.mb-chev{color:var(--subtle);font-size:8px;flex-shrink:0;}
.mb-footer{padding:5px 7px;border-top:1px solid var(--sand);background:rgba(0,40,80,.02);text-align:center;}
.mb-footer-link{font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--navy);}
/* ========== DESKTOP MOCK (640px wide = ~1160 content-col × .55) ========== */
.d-frame{width:640px;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:14px;}
.d-eyebrow{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}
.d-eyebrow-l{font-size:9px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
.d-eyebrow-count{background:var(--navy);color:#fff;font-size:9px;font-weight:700;padding:2px 8px;border-radius:10px;}
.d-block{background:#fff;border:1px solid var(--sand);border-radius:4px;overflow:hidden;}
.d-row{display:flex;align-items:center;padding:9px 14px;gap:10px;border-bottom:1px solid var(--sand);cursor:pointer;}
.d-row:last-child{border-bottom:none;}
.d-row:hover{background:rgba(166,218,216,.08);}
.d-row.focused{background:rgba(166,218,216,.08);box-shadow:inset 0 0 0 2px var(--navy);}
.d-icon{width:20px;height:20px;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;}
.d-icon.pdf{background:rgba(168,50,50,.12);color:var(--red);}
.d-icon.jpg{background:rgba(46,110,57,.12);color:var(--green);}
.d-icon.tif{background:rgba(91,94,166,.12);color:#5B5EA6;}
.d-main{flex:1;min-width:0;}
.d-title{font-family:Georgia,serif;font-size:12px;color:var(--navy);line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.d-meta{font-size:9px;color:var(--muted);margin-top:2px;}
.d-chev{color:var(--subtle);font-size:14px;flex-shrink:0;}
.d-footer{padding:8px 14px;border-top:1px solid var(--sand);background:rgba(0,40,80,.02);display:flex;justify-content:flex-end;}
.d-footer-link{font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--navy);}
/* Empty / not shown state */
.empty-note{background:rgba(46,110,57,.06);border:1px dashed rgba(46,110,57,.25);border-radius:4px;padding:14px 16px;font-size:11px;color:var(--green);text-align:center;font-style:italic;}
/* After-upload banner */
.banner{background:var(--navy);color:#fff;border-radius:4px;padding:10px 14px;display:flex;align-items:center;gap:10px;width:640px;margin-bottom:12px;}
.banner-check{width:18px;height:18px;border-radius:50%;background:var(--mint);color:var(--navy);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;}
.banner-t{font-size:11px;flex:1;}
.banner-t strong{color:var(--mint);}
.banner-cta{background:var(--mint);color:var(--navy);font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;padding:4px 10px;border-radius:3px;}
.banner-x{color:rgba(255,255,255,.5);font-size:14px;cursor:pointer;padding:0 4px;}
/* Impl-ref table */
.impl-ref{background:#fff;border:1px solid var(--border);border-radius:6px;overflow:hidden;margin-top:20px;}
.impl-ref table{width:100%;border-collapse:collapse;}
.impl-ref th{background:rgba(0,40,80,.04);font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 12px;text-align:left;border-bottom:1px solid var(--border);}
.impl-ref td{font-size:11px;color:var(--text);padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top;line-height:1.55;}
.impl-ref tr:last-child td{border-bottom:none;}
.impl-ref td:first-child{font-weight:700;color:var(--navy);white-space:nowrap;}
.impl-ref code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;color:var(--navy);}
.impl-ref td.px{font-family:var(--mono);font-size:10px;color:var(--muted);white-space:nowrap;}
.impl-ref td.note{font-size:10px;color:var(--muted);font-style:italic;}
/* A11y box */
.a11y-box{background:rgba(91,94,166,.06);border-left:3px solid #5B5EA6;border-radius:0 4px 4px 0;padding:14px 18px;}
.a11y-box h4{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#5B5EA6;margin-bottom:8px;}
.a11y-box ul{list-style:none;padding-left:0;}
.a11y-box li{font-size:12px;color:var(--text);padding-left:18px;position:relative;margin-bottom:6px;line-height:1.55;}
.a11y-box li::before{content:'✓';position:absolute;left:0;color:#5B5EA6;font-weight:700;}
.a11y-box code{font-family:var(--mono);font-size:10px;background:rgba(91,94,166,.1);padding:1px 4px;border-radius:2px;}
/* Two-col grid */
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;}
.two-col h3{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:6px;letter-spacing:.02em;}
.two-col p{font-size:12px;color:var(--muted);line-height:1.55;}
/* Inline code */
code.inline{font-family:var(--mono);font-size:11px;background:rgba(0,40,80,.06);padding:1px 5px;border-radius:3px;color:var(--navy);}
</style>
</head>
<body>
<div class="doc">
<!-- ===== HEADER ===== -->
<div class="hdr">
<div class="badges">
<span class="badge">DASHBOARD</span>
<span class="badge badge-g">NEW BLOCK</span>
<span class="badge badge-g">MOBILE-FIRST</span>
</div>
<h1>Ergänzungs-Liste — Dashboard-Block</h1>
<div class="hdr-meta">Familienarchiv · 2026-04-20 · Leonie Voss, UX Lead</div>
</div>
<div class="decision-box">
<h2>The call</h2>
<p class="prose">A dedicated list-block on the dashboard, placed <strong>between the Resume strip and the MissionControlStrip</strong>, surfaces the documents that were just uploaded and still need metadata. It replaces the orphaned <code class="inline">DashboardNeedsMetadata.svelte</code> component with a spec'd, senior-friendly row design.</p>
<p class="prose"><strong>Why here, not a 4th column in MissionControlStrip:</strong> (1) strip is already at 3-column visual capacity; (2) batch-upload reality (1015 docs arriving at once) makes a count tile useless — seniors need to see <em>which</em> docs are waiting; (3) placing it directly under the DropZone sightline makes upload→enrich the most visually-coupled pair on the page.</p>
<p class="prose"><strong>Scope of this spec:</strong> the block itself (layout, row anatomy, empty/loading/error states, a11y, responsive behavior at 320/768/1440). The post-upload success banner is included as a coupled interaction. The strip redesign, pagination, and the other three pipeline stages (segment / transcribe / review) are <em>not</em> in scope — they earn their own specs.</p>
</div>
<!-- ===== SECTION 1 — ANATOMY ===== -->
<div class="sec">
<div class="sec-label">01 — Block anatomy</div>
<h2 class="sec-title">Three pieces: eyebrow, list, footer</h2>
<p class="sec-sub">The block is a single <code class="inline">&lt;section&gt;</code> landmark with an eyebrow heading, a divided list of up to 5 rows, and an optional footer link when more than 5 docs are pending. At <code class="inline">incompleteDocs.length === 0</code> the block renders <strong>nothing</strong> — no "all clear" state, no empty card. A clean dashboard means no work to do.</p>
<div class="frames-row">
<div>
<div class="m-phone">
<div class="m-status"></div>
<div class="m-body">
<div class="mb-eyebrow">
<span class="mb-eyebrow-l">Benötigen Metadaten</span>
<span class="mb-eyebrow-count">12</span>
</div>
<div class="mb-block">
<div class="mb-row">
<div class="mb-icon pdf">PDF</div>
<div class="mb-main">
<div class="mb-title">Brief Oma Hilde 1962</div>
<div class="mb-meta">vor 2 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-row">
<div class="mb-icon pdf">PDF</div>
<div class="mb-main">
<div class="mb-title">Geburtsurkunde Opa</div>
<div class="mb-meta">vor 2 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-row">
<div class="mb-icon jpg">JPG</div>
<div class="mb-main">
<div class="mb-title">Foto Hochzeit Tante Elsa</div>
<div class="mb-meta">vor 2 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-row">
<div class="mb-icon pdf">PDF</div>
<div class="mb-main">
<div class="mb-title">Postkarte Bodensee</div>
<div class="mb-meta">vor 2 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-row">
<div class="mb-icon pdf">PDF</div>
<div class="mb-main">
<div class="mb-title">Meldebescheinigung 1971</div>
<div class="mb-meta">vor 3 Min.</div>
</div>
<div class="mb-chev"></div>
</div>
<div class="mb-footer">
<span class="mb-footer-link">Alle 12 anzeigen →</span>
</div>
</div>
</div>
</div>
<span class="frame-caption">320px · 12 docs pending · 5 shown, rest via footer link to /enrich</span>
</div>
<div>
<div class="d-frame">
<div class="d-eyebrow">
<span class="d-eyebrow-l">Benötigen Metadaten</span>
<span class="d-eyebrow-count">12</span>
</div>
<div class="d-block">
<div class="d-row">
<div class="d-icon pdf">PDF</div>
<div class="d-main">
<div class="d-title">Brief Oma Hilde 1962 — An meinen lieben Ludwig</div>
<div class="d-meta">vor 2 Minuten hochgeladen · 3 Seiten</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-row focused">
<div class="d-icon pdf">PDF</div>
<div class="d-main">
<div class="d-title">Geburtsurkunde Opa Friedrich</div>
<div class="d-meta">vor 2 Minuten hochgeladen · 1 Seite</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-row">
<div class="d-icon jpg">JPG</div>
<div class="d-main">
<div class="d-title">Foto Hochzeit Tante Elsa — Standesamt Ulm</div>
<div class="d-meta">vor 2 Minuten hochgeladen</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-row">
<div class="d-icon pdf">PDF</div>
<div class="d-main">
<div class="d-title">Postkarte Bodensee 1958</div>
<div class="d-meta">vor 2 Minuten hochgeladen · 2 Seiten</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-row">
<div class="d-icon tif">TIF</div>
<div class="d-main">
<div class="d-title">Meldebescheinigung Stadt Tübingen 1971</div>
<div class="d-meta">vor 3 Minuten hochgeladen · 1 Seite</div>
</div>
<div class="d-chev"></div>
</div>
<div class="d-footer">
<span class="d-footer-link">Alle 12 anzeigen →</span>
</div>
</div>
</div>
<span class="frame-caption">1440px · main content column (768960px target) · 12 docs pending · row 2 shows keyboard focus state</span>
</div>
</div>
<div class="callout navy">
<div><strong>Row cap = 5.</strong> When pending docs exceed 5, the block shows the 5 most-recently-uploaded and a footer link to <code class="inline">/enrich</code>. This caps block height so the dashboard stays navigable, and reinforces <code class="inline">/enrich</code> as the canonical "long queue" page.</div>
</div>
</div>
<!-- ===== SECTION 2 — ROW ANATOMY ===== -->
<div class="sec">
<div class="sec-label">02 — Row anatomy</div>
<h2 class="sec-title">Icon · title · upload time · chevron</h2>
<p class="sec-sub">The entire row is one <code class="inline">&lt;a href="/enrich/{id}"&gt;</code>. No nested interactive elements — one tap, one destination. Touch target: <strong>72px minimum row height on mobile, 64px on desktop</strong>. Both exceed the 48px WCAG 2.2 AA floor for the senior audience.</p>
<div class="two-col">
<div>
<h3>Left: file-type badge (20×20px)</h3>
<p>Visual differentiation between PDF / JPG / PNG / TIF. Not decorative — gives seniors a scannable category cue without reading. Uses redundant color + text ("PDF", "JPG") so it passes WCAG 1.4.1.</p>
</div>
<div>
<h3>Center: title + upload time</h3>
<p>Title in <code class="inline">font-serif text-base</code> (16px mobile) / <code class="inline">text-lg</code> (18px desktop). Truncated with <code class="inline">text-ellipsis</code> on narrow viewports. Relative time ("vor 2 Min.") in <code class="inline">font-sans text-xs text-ink-2</code>.</p>
</div>
</div>
<div class="two-col">
<div>
<h3>Right: chevron (decorative)</h3>
<p>Navigation affordance. <code class="inline">aria-hidden="true"</code>. Becomes slightly more prominent on hover (<code class="inline">opacity-30 → 70</code>).</p>
</div>
<div>
<h3>States: hover, focus-visible, active</h3>
<p>Hover: <code class="inline">bg-brand-mint/10</code> wash. Focus-visible: <code class="inline">ring-2 ring-brand-navy ring-offset-2</code> <em>inside</em> the row (visible against row bg). Active: <code class="inline">bg-brand-mint/20</code>.</p>
</div>
</div>
<div class="callout orange">
<div><strong>DTO extension needed.</strong> Current <code class="inline">IncompleteDocumentDTO</code> only carries <code class="inline">id</code> and <code class="inline">title</code>. To render "vor 2 Min." and the file-type badge, add <code class="inline">uploadedAt: Instant</code> and <code class="inline">mimeType: String</code>. Small, cheap backend change. Without these fields the block still works (skip meta line, use a generic doc icon) but the senior-facing UX is meaningfully worse.</div>
</div>
</div>
<!-- ===== SECTION 3 — STATES ===== -->
<div class="sec">
<div class="sec-label">03 — States</div>
<h2 class="sec-title">Empty · loading · error · after-upload</h2>
<div class="two-col" style="margin-bottom:24px;">
<div>
<h3>Empty (<code class="inline">length === 0</code>)</h3>
<p>Render <code class="inline">null</code>. The block disappears entirely. Seniors don't need a "nothing to do" card — absence is clear.</p>
<div class="empty-note" style="margin-top:10px;">(block not rendered)</div>
</div>
<div>
<h3>Loading</h3>
<p>On initial dashboard SSR this data comes in with the page load — no spinner needed. If you later add client-side refresh, use a skeleton (3 rows of gray blocks at 72px height, <code class="inline">animate-pulse</code>, respects <code class="inline">prefers-reduced-motion</code>).</p>
</div>
</div>
<div class="two-col">
<div>
<h3>Error</h3>
<p>If the <code class="inline">/api/documents/incomplete</code> call fails, render the block in a muted error state: eyebrow reads "Liste konnte nicht geladen werden", with a retry link. Do not suppress — the user needs to know their queue may be out of date.</p>
</div>
<div>
<h3>After-upload (immediately after DropZone fires)</h3>
<p>Transient success banner above the block. Auto-dismiss after 8s, <strong>with</strong> a manual close X. Content: "12 Dokumente hochgeladen" + "Jetzt ergänzen →" CTA → scrolls/focuses the list block. Use <code class="inline">aria-live="polite"</code> so screen readers announce the count.</p>
</div>
</div>
<!-- Banner mockup -->
<div style="margin-top:24px;">
<div class="banner">
<div class="banner-check"></div>
<div class="banner-t"><strong>12 Dokumente hochgeladen.</strong> Jetzt ergänzen, damit sie durchsuchbar werden.</div>
<span class="banner-cta">Ergänzen →</span>
<span class="banner-x">×</span>
</div>
<span class="frame-caption">After-upload success banner · renders above the list block · auto-dismiss 8s · manual dismiss always available</span>
</div>
<div class="callout green" style="margin-top:16px;">
<div><strong>No auto-redirect.</strong> After a batch of 12 uploads, dumping the user into <code class="inline">/enrich/{firstId}</code> is disorienting. The banner gives them the moment without taking control away. The list block becomes the persistent landing spot for every return visit.</div>
</div>
</div>
<!-- ===== SECTION 4 — RESPONSIVE ===== -->
<div class="sec">
<div class="sec-label">04 — Responsive behavior</div>
<h2 class="sec-title">Mobile-first, three breakpoints</h2>
<div class="impl-ref">
<table>
<thead>
<tr><th style="width:90px;">Breakpoint</th><th>Row height</th><th>Title size</th><th>Meta visibility</th><th>Padding</th></tr>
</thead>
<tbody>
<tr>
<td>320px</td>
<td class="px">72px</td>
<td class="px">text-base (16px)</td>
<td>Relative time only</td>
<td class="px">px-3 py-3</td>
</tr>
<tr>
<td>768px</td>
<td class="px">72px</td>
<td class="px">text-lg (18px)</td>
<td>Time + page count</td>
<td class="px">px-5 py-4</td>
</tr>
<tr>
<td>1440px</td>
<td class="px">64px</td>
<td class="px">text-lg (18px)</td>
<td>Time + page count</td>
<td class="px">px-6 py-4</td>
</tr>
</tbody>
</table>
</div>
<div class="callout navy" style="margin-top:16px;">
<div><strong>Block width follows the main content column.</strong> On the dashboard grid this is <code class="inline">1fr</code> minus the DropZone sidebar — not the full viewport. At 1440px total width, the block lands around 900960px wide.</div>
</div>
</div>
<!-- ===== SECTION 5 — IMPL REF ===== -->
<div class="sec">
<div class="sec-label">05 — Implementation reference</div>
<h2 class="sec-title">Exact Tailwind classes & pixel values</h2>
<p class="sec-sub">Rewire and extend <code class="inline">frontend/src/lib/components/DashboardNeedsMetadata.svelte</code>. The component already exists and is correctly typed — the changes are: new row anatomy, file-type icon, relative time, 5-item cap with footer, a11y landmark, and focus/hover states.</p>
<div class="impl-ref">
<table>
<thead>
<tr><th style="width:170px;">Region</th><th>Tailwind</th><th>Pixel values</th><th class="note">Notes</th></tr>
</thead>
<tbody>
<tr>
<td>Section wrapper</td>
<td><code>&lt;section aria-labelledby="enrich-heading" class="mb-6"&gt;</code></td>
<td class="px">margin-bottom 24px</td>
<td class="note">Landmark for SR nav</td>
</tr>
<tr>
<td>Eyebrow row</td>
<td><code>flex items-center justify-between mb-3 px-1</code></td>
<td class="px">mb 12px</td>
<td class="note">Title + count badge</td>
</tr>
<tr>
<td>Eyebrow heading</td>
<td><code>text-xs font-bold uppercase tracking-widest text-gray-500</code></td>
<td class="px">12px / 700</td>
<td class="note">Not gray-400 — must pass AA on sand bg</td>
</tr>
<tr>
<td>Count badge</td>
<td><code>bg-brand-navy text-white text-xs font-bold px-2 py-0.5 rounded-full</code></td>
<td class="px">12px / 700</td>
<td class="note">Use <code>aria-live="polite"</code></td>
</tr>
<tr>
<td>List container</td>
<td><code>bg-white border border-line rounded-sm shadow-sm overflow-hidden</code></td>
<td class="px">border 1px, radius 2px</td>
<td class="note">Matches card pattern</td>
</tr>
<tr>
<td>List element</td>
<td><code>&lt;ol class="divide-y divide-line-2"&gt;</code></td>
<td class="px">divider 1px</td>
<td class="note">Ordered — upload time is the order</td>
</tr>
<tr>
<td>Row link</td>
<td><code>group flex items-center gap-3 px-3 py-3 md:px-5 md:py-4 min-h-[72px] lg:min-h-[64px] hover:bg-brand-mint/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset transition-colors</code></td>
<td class="px">min-height 72px mobile / 64px desktop</td>
<td class="note">Whole row is the &lt;a&gt;</td>
</tr>
<tr>
<td>File-type badge</td>
<td><code>shrink-0 w-5 h-5 md:w-6 md:h-6 rounded-sm flex items-center justify-center text-[10px] font-bold</code></td>
<td class="px">20×20px mobile / 24×24 desktop</td>
<td class="note">Color per mime: red/PDF, green/JPG&#x7c;PNG, purple/TIF</td>
</tr>
<tr>
<td>Title</td>
<td><code>font-serif text-base md:text-lg text-ink group-hover:underline truncate</code></td>
<td class="px">16px mobile / 18px desktop</td>
<td class="note">Single-line truncate</td>
</tr>
<tr>
<td>Meta line</td>
<td><code>font-sans text-xs text-ink-2 mt-0.5</code></td>
<td class="px">12px</td>
<td class="note">"vor N Min./Std." + optional "· N Seiten"</td>
</tr>
<tr>
<td>Chevron</td>
<td><code>shrink-0 w-5 h-5 opacity-30 group-hover:opacity-70 transition-opacity</code></td>
<td class="px">20×20px</td>
<td class="note">Use <code>aria-hidden="true"</code></td>
</tr>
<tr>
<td>Footer (when &gt;5)</td>
<td><code>border-t border-line bg-brand-sand/20 px-5 py-3 flex justify-end</code></td>
<td class="px">py 12px</td>
<td class="note">Only render when <code>length &gt; 5</code></td>
</tr>
<tr>
<td>Footer link</td>
<td><code>font-sans text-xs font-bold uppercase tracking-widest text-brand-navy hover:underline</code></td>
<td class="px">12px / 700</td>
<td class="note">"Alle {n} anzeigen →"</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ===== SECTION 6 — ACCESSIBILITY ===== -->
<div class="sec">
<div class="sec-label">06 — Accessibility (WCAG 2.2 AA)</div>
<h2 class="sec-title">What axe-playwright must confirm</h2>
<div class="a11y-box">
<h4>A11y contract</h4>
<ul>
<li><strong>Landmark:</strong> <code>&lt;section aria-labelledby="enrich-heading"&gt;</code> with an <code>&lt;h2 id="enrich-heading"&gt;</code>. SR users reach the block via landmark nav.</li>
<li><strong>Ordered list:</strong> <code>&lt;ol&gt;</code>, not <code>&lt;div&gt;</code>. Upload time <em>is</em> the implicit order.</li>
<li><strong>Count live region:</strong> count badge gets <code>aria-live="polite"</code> so SR announces "12 Dokumente benötigen Metadaten" when the number changes after upload.</li>
<li><strong>Touch target:</strong> 72px row height on mobile ≫ WCAG 2.2 floor of 48px. The whole row is tappable, not just the title.</li>
<li><strong>Focus visibility:</strong> <code>focus-visible:ring-2 ring-brand-navy ring-inset</code>. Outer ring would clip; inner ring is always visible against hover/active bg.</li>
<li><strong>File-type cue:</strong> color <em>plus</em> text ("PDF", "JPG") — passes 1.4.1 (not color alone).</li>
<li><strong>Contrast:</strong> title <code>text-ink</code> on white = 14.5:1 (AAA). Meta <code>text-ink-2</code> on white — must verify ≥4.5:1 in <em>both</em> light and dark mode.</li>
<li><strong>Reduced motion:</strong> skeleton pulse and hover transitions respect <code>prefers-reduced-motion</code> (transition-duration 0.01ms).</li>
<li><strong>Banner:</strong> <code>role="status" aria-live="polite"</code>; manual dismiss button labeled <code>aria-label="Benachrichtigung schließen"</code>.</li>
</ul>
</div>
</div>
<!-- ===== SECTION 7 — DARK MODE ===== -->
<div class="sec">
<div class="sec-label">07 — Dark mode</div>
<h2 class="sec-title">Works via semantic tokens, verify contrast separately</h2>
<p class="sec-sub">All colors come from existing tokens (<code class="inline">bg-white</code>, <code class="inline">text-ink</code>, <code class="inline">text-ink-2</code>, <code class="inline">border-line</code>, <code class="inline">bg-surface</code>). No hard-coded hex values. Dark mode inherits the remapped tokens.</p>
<div class="callout orange">
<div><strong>Verify in dark mode:</strong> the file-type badges use mime-specific colors (red/green/purple) against white—in dark mode those same colors sit on a near-black background. Test each badge at its dark-mode contrast ratio; some may need lightness adjustment (e.g. <code class="inline">dark:text-red-300</code> instead of <code class="inline">text-red-600</code>). Run axe-playwright in both themes per project convention.</div>
</div>
</div>
<!-- ===== SECTION 8 — OUT OF SCOPE ===== -->
<div class="sec">
<div class="sec-label">08 — Explicitly out of scope</div>
<h2 class="sec-title">What this spec does NOT cover</h2>
<div class="callout navy">
<div>
<strong>Future work (separate specs):</strong>
<ul style="margin-top:8px;padding-left:20px;">
<li>List-block treatment for the <em>other</em> pipeline stages (segment, transcribe, review). If this pattern works, they likely graduate out of MissionControlStrip too.</li>
<li>Bulk-enrich ("same sender for all", "apply these 3 tags to all") — a natural follow-up for batch-upload reality.</li>
<li>Sort/filter on <code class="inline">/enrich</code> (by upload date, by file type, by uploader).</li>
<li>Strip redesign once a 4th+ pipeline stage forces the issue.</li>
<li>Thumbnails in rows (deferred — depends on backend preview generation).</li>
</ul>
</div>
</div>
</div>
<!-- ===== TEST PLAN ===== -->
<div class="sec">
<div class="sec-label">09 — Test plan</div>
<h2 class="sec-title">What QA (Sara) will verify</h2>
<div class="impl-ref">
<table>
<thead><tr><th style="width:170px;">Test</th><th>What it verifies</th></tr></thead>
<tbody>
<tr><td>Vitest: component renders</td><td>With 0 docs, renders nothing. With 15, no footer. With 6+, footer link shows "Alle {n} anzeigen".</td></tr>
<tr><td>Vitest: count badge</td><td>Badge reflects <code>incompleteDocs.length</code>, not capped list length.</td></tr>
<tr><td>Playwright: axe, light mode</td><td>Dashboard passes a11y with block populated. Focus ring visible on keyboard nav through rows.</td></tr>
<tr><td>Playwright: axe, dark mode</td><td>Same as above with <code>[data-theme="dark"]</code>. File-type badge contrast ratios verified.</td></tr>
<tr><td>Playwright: 320/768/1440 screenshots</td><td>Block renders at all three breakpoints without overflow or truncation beyond title.</td></tr>
<tr><td>Playwright: upload → banner → click CTA</td><td>After DropZone fires with 3+ files: banner appears, list block populates, CTA scrolls/focuses list. Banner auto-dismisses at 8s. Manual X dismisses immediately.</td></tr>
<tr><td>Playwright: keyboard flow</td><td>Tab from DropZone reaches banner (if present), then list rows in order, then footer link. Enter on row navigates to <code>/enrich/{id}</code>.</td></tr>
</tbody>
</table>
</div>
</div>
<hr>
<p style="font-family:var(--mono);font-size:10px;color:var(--muted);text-align:center;">
Leonie Voss · UX Lead · Familienarchiv · 2026-04-20
</p>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,9 @@ import { test, expect } from '@playwright/test';
const AUTHENTICATED_PAGES = [
{ name: 'home', path: '/' },
{ name: 'persons', path: '/persons' },
{ name: 'admin', path: '/admin' }
{ name: 'aktivitaeten', path: '/aktivitaeten' },
{ name: 'admin', path: '/admin' },
{ name: 'admin-system', path: '/admin/system' }
];
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {

View File

@@ -248,3 +248,28 @@ test.describe('Admin system tab — backfill file hashes', () => {
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
});
});
// ─── System tab — generate thumbnails ─────────────────────────────────────────
test.describe('Admin system tab — generate thumbnails', () => {
test('admin triggers thumbnail generation and sees DONE within 30s', async ({ page }) => {
test.setTimeout(45_000);
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
// Navigate to System tab
await page.getByRole('button', { name: /system/i }).click();
const btn = page
.locator('[data-thumbnails-trigger]')
.or(page.getByRole('button', { name: /thumbnails erzeugen/i }));
await expect(btn.first()).toBeVisible();
await btn.first().click();
// Status transitions RUNNING → DONE; poll message shows the final summary
await expect(page.getByTestId('thumbnails-status-done')).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: 'test-results/e2e/admin-generate-thumbnails.png' });
});
});

View File

@@ -12,12 +12,12 @@ const authFile = path.join(__dirname, '.auth/user.json');
* E2E_PASSWORD (default: admin123)
*/
setup('authenticate', async ({ page }) => {
const username = process.env.E2E_USERNAME ?? 'admin';
const username = process.env.E2E_USERNAME ?? 'admin@familyarchive.local';
const password = process.env.E2E_PASSWORD ?? 'admin123';
await page.goto('/login');
await page.getByLabel('Benutzername').fill(username);
await page.getByLabel('Passwort').fill(password);
await page.getByLabel(/e-mail/i).fill(username);
await page.getByLabel(/passwort/i).fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForURL('/');

View File

@@ -0,0 +1,61 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
// [data-hydrated] is set by +layout.svelte once SvelteKit's client-side hydration is complete.
// Waiting on it ensures the component is interactive before we interact with it.
test.describe('BackButton — navigation', () => {
test('returns to previous page via history when clicked', async ({ page }) => {
// Navigate to persons list, then to a person detail
await page.goto('/persons');
await page.waitForSelector('[data-hydrated]');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
const personHref = await firstPersonLink.getAttribute('href');
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
// Now navigate to the edit page from the detail page
const editLink = page.locator('a[href$="/edit"]').first();
await editLink.click();
await page.waitForURL(/\/persons\/.+\/edit/);
// Click the BackButton — should return to person detail, not /persons list
const backBtn = page.getByRole('button', { name: /zurück/i });
await expect(backBtn).toBeVisible();
await backBtn.click();
// Should return to the person detail URL (history.back()), not the static /persons
await expect(page).toHaveURL(new RegExp(personHref!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});
});
test.describe('BackButton — accessibility', () => {
test('touch target is at least 44px tall on /persons/new', async ({ page }) => {
await page.goto('/persons/new');
await page.waitForSelector('[data-hydrated]');
const backBtn = page.getByRole('button', { name: /zurück/i });
await expect(backBtn).toBeVisible();
const box = await backBtn.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
});
test('passes axe-core wcag2a/wcag2aa scan on /persons/new', async ({ page }) => {
await page.goto('/persons/new');
await page.waitForSelector('[data-hydrated]');
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
if (results.violations.length > 0) {
const summary = results.violations
.map((v) => `[${v.impact}] ${v.id}: ${v.description}`)
.join('\n');
console.log(`\nAxe violations on /persons/new:\n${summary}`);
}
expect(results.violations).toEqual([]);
});
});

View File

@@ -0,0 +1,65 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
import {
seedBilateralPair,
cleanupBilateralPair,
type BilateralPair
} from './fixtures/bilateral-correspondence';
// Accessibility coverage for the briefwechsel thumbnail-row layout. Seeds
// two persons + a bilateral document via the shared fixture so the page
// reaches the results state (not the hero), then runs axe-core
// (wcag2a + wcag2aa) across three viewports and two color schemes.
const VIEWPORTS = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 }
] as const;
const THEMES = ['light', 'dark'] as const;
let pair: BilateralPair;
test.describe('Accessibility — /briefwechsel row layout', () => {
test.beforeAll(async ({ request }) => {
pair = await seedBilateralPair(request, 'A11y');
});
test.afterAll(async ({ request }) => {
await cleanupBilateralPair(request, pair);
});
for (const vp of VIEWPORTS) {
for (const theme of THEMES) {
test(`${vp.name} / ${theme} has no wcag2a/wcag2aa violations`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.emulateMedia({ colorScheme: theme });
await page.goto(
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
);
await page.waitForSelector('[data-hydrated]');
// Assert we actually reached the row layout, not the hero — otherwise
// the axe sweep silently scans the wrong DOM.
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.include('main')
.analyze();
if (results.violations.length > 0) {
const summary = results.violations
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n');
console.log(
`\nAccessibility violations on briefwechsel (${vp.name}/${theme}):\n${summary}`
);
}
expect(results.violations).toEqual([]);
});
}
}
});

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import {
seedBilateralPair,
cleanupBilateralPair,
type BilateralPair
} from './fixtures/bilateral-correspondence';
// Visual + structural coverage for the new briefwechsel row layout.
//
// Seeds a bilateral correspondence pair via the shared fixture so the page
// reaches the row state. The structural test asserts that a
// ConversationThumbnail tile AND the DistributionBar render — regressions
// that silently drop to the hero or break the {#each} wiring fail here.
//
// Snapshot assertions are gated on the VISUAL env flag because they need
// pre-captured baselines (see `playwright test --update-snapshots` to
// regenerate after intentional UI changes). CI can opt in via VISUAL=1.
const VISUAL = process.env.VISUAL === '1';
let pair: BilateralPair;
test.describe('Briefwechsel — thumbnail-row layout', () => {
test.beforeAll(async ({ request }) => {
pair = await seedBilateralPair(request, 'Visual');
});
test.afterAll(async ({ request }) => {
await cleanupBilateralPair(request, pair);
});
async function openBilateral(page: import('@playwright/test').Page) {
await page.goto(
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
);
await page.waitForSelector('[data-hydrated]');
// Parity with the a11y spec: fail loudly if we ever end up on the hero
// instead of the row layout.
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
}
test('renders a ConversationThumbnail tile and the DistributionBar', async ({ page }) => {
await openBilateral(page);
// Tile appears for the seeded document
await expect(page.locator('[data-testid="conv-thumb-tile"]').first()).toBeVisible();
// DistributionBar is present (role=img with a descriptive aria-label)
const bar = page.locator('[role="img"]');
await expect(bar).toBeVisible();
const label = (await bar.getAttribute('aria-label')) ?? '';
expect(label.length).toBeGreaterThan(0);
});
// Visual regression — one snapshot per (viewport × theme). Tolerance stays
// generous (maxDiffPixels: 100) so antialiasing jitter doesn't flip them on
// unrelated runs; genuine layout changes are still caught because the
// thumbnail tile and distribution bar dominate the frame.
test.describe('snapshots', () => {
test.skip(!VISUAL, 'VISUAL=1 required to compare baselines');
for (const viewport of [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 }
] as const) {
for (const theme of ['light', 'dark'] as const) {
test(`${viewport.name} / ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.emulateMedia({ colorScheme: theme });
await openBilateral(page);
await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, {
maxDiffPixels: 100,
fullPage: true
});
});
}
}
});
});

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

@@ -0,0 +1,83 @@
/**
* Dashboard enrichment-list-block (issue #296) — full upload → banner → CTA journey,
* plus axe sweep in light and dark mode at 320 / 768 / 1440 viewports.
*
* The uploaded PDFs are deleted in afterEach so this spec does not pollute
* the dev DB between runs.
*/
import AxeBuilder from '@axe-core/playwright';
import { test, expect, type Page } from '@playwright/test';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const VIEWPORTS = [
{ name: '320', width: 320, height: 720 },
{ name: '768', width: 768, height: 1024 },
{ name: '1440', width: 1440, height: 900 }
];
const psql = (sql: string) =>
execSync(
`docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"`
);
test.afterEach(() => {
// Remove any document whose filename matches the seeded sentinel — keeps the
// DB clean for subsequent test runs.
psql(`DELETE FROM documents WHERE original_filename IN ('minimal.pdf', 'minimal2.pdf');`);
});
async function uploadViaDropZone(page: Page, files: string[]) {
const inputLocator = page.locator('input[type="file"][accept*="pdf"]');
await inputLocator.first().setInputFiles(files);
}
test.describe('Dashboard enrichment block — upload → banner → CTA', () => {
test('banner appears after upload and CTA navigates to /enrich', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
const fixturePath = (name: string) => path.join(__dirname, 'fixtures', name);
await uploadViaDropZone(page, [fixturePath('minimal.pdf'), fixturePath('minimal2.pdf')]);
const banner = page.getByRole('status').filter({ hasText: /hochgeladen/ });
await expect(banner).toBeVisible();
await expect(banner).toContainText(/2 Dokumente/);
await banner.getByRole('link', { name: /ergänzen/i }).click();
await expect(page).toHaveURL(/\/enrich(\/|$)/);
});
});
test.describe('Dashboard enrichment block — axe sweep', () => {
for (const viewport of VIEWPORTS) {
for (const scheme of ['light', 'dark'] as const) {
test(`no wcag2a/wcag2aa violations at ${viewport.name}px (${scheme})`, async ({
browser
}) => {
const context = await browser.newContext({
colorScheme: scheme,
viewport: { width: viewport.width, height: viewport.height },
storageState: path.join(__dirname, '.auth/user.json')
});
const page = await context.newPage();
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
if (results.violations.length > 0) {
console.log(
`Violations on dashboard @ ${viewport.name}px ${scheme}:\n` +
results.violations.map((v) => `[${v.impact}] ${v.id}`).join('\n')
);
}
expect(results.violations).toEqual([]);
await context.close();
});
}
}
});

View File

@@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
test.describe('DocumentTopBar — back navigation', () => {
test('BackButton returns to /documents after navigating from the documents list', async ({
page
}) => {
// Navigate to home page first (mirrors the real user flow)
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
// Click the Dokumente nav link (SPA navigation — pushes to history)
await page.click('a[href="/documents"]');
await page.waitForURL('/documents');
await page.waitForSelector('[data-hydrated]');
// Click first real document link (skip /documents/new and edit links)
const docLink = page.locator('a[href^="/documents/"]:not([href="/documents/new"])').first();
const count = await docLink.count();
if (count === 0) {
test.skip(true, 'No documents in test database');
}
const docHref = await docLink.getAttribute('href');
await docLink.click();
await page.waitForURL(new RegExp(docHref!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
await page.waitForSelector('[data-hydrated]');
// Find and click the BackButton in the DocumentTopBar
const backBtn = page.locator('[data-topbar]').getByRole('button', { name: /zurück/i });
await expect(backBtn).toBeVisible();
await backBtn.click();
// Should be back at the documents list, not at /
await page.waitForURL(/\/documents($|\?)/);
expect(page.url()).toMatch(/\/documents($|\?)/);
});
});

View File

@@ -0,0 +1,62 @@
import type { APIRequestContext } from '@playwright/test';
/**
* Test fixture for the briefwechsel row layout.
*
* Creates two persons and one document with sender/receiver between them so
* that `/briefwechsel?senderId=X&receiverId=Y` navigates straight to the row
* state (not the hero). Each seed uses a `Date.now()`-suffixed last name so
* parallel runs and reruns never collide.
*
* The backend does not expose a person-delete endpoint, so only the document
* is cleaned up in {@link cleanupBilateralPair}. The two timestamped persons
* remain in the DB — acceptable for the test environment, and the unique
* suffix means they cannot conflict with later runs.
*/
export interface BilateralPair {
senderId: string;
receiverId: string;
documentId: string;
}
export async function seedBilateralPair(
request: APIRequestContext,
prefix: string
): Promise<BilateralPair> {
const timestamp = Date.now();
const senderRes = await request.post('/api/persons', {
data: { firstName: prefix, lastName: `Sender-${timestamp}` }
});
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
const senderId = (await senderRes.json()).id as string;
const receiverRes = await request.post('/api/persons', {
data: { firstName: prefix, lastName: `Receiver-${timestamp}` }
});
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
const receiverId = (await receiverRes.json()).id as string;
const docRes = await request.post('/api/documents', {
multipart: {
title: `${prefix} Brief`,
documentDate: '1950-06-15',
senderId,
receiverIds: receiverId
}
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
const documentId = (await docRes.json()).id as string;
return { senderId, receiverId, documentId };
}
export async function cleanupBilateralPair(
request: APIRequestContext,
pair: BilateralPair
): Promise<void> {
// Only the document is purged — the backend has no person-delete endpoint
// and the timestamped last names make orphaned person rows safe to leave.
await request.delete(`/api/documents/${pair.documentId}`);
}

View File

@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
import { createEmptyDocument } from './helpers/upload-empty-document.js';
test.describe('Help chip — Read/Edit panel header', () => {
let docId: string;
test.beforeAll(async ({ request }) => {
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();
// 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 (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.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
).not.toBeVisible();
// Focus should have returned to the chip
await expect(helpBtn).toBeFocused();
});
});

View File

@@ -0,0 +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 createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Transcribe Coach Test' }
});
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

@@ -0,0 +1,117 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { AxeBuilder } from '@axe-core/playwright';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
/**
* E2E test for the notification deep-link scroll flow — issue #276.
*
* Seeds a document + transcription block + block comment via API, then
* visits /documents/{id}?commentId=X&annotationId=Y and verifies:
* - page enters transcribe mode
* - the target comment is visible in the viewport
* - focus lands on the comment article
* - URL query params are stripped after handling
*/
let docHref: string;
let docId: string;
let annotationId: string;
let commentId: string;
test.describe('Notification deep-link scroll', () => {
test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' }
});
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json();
docId = doc.id;
docHref = `${baseURL}/documents/${docId}`;
const uploadRes = await request.put(`/api/documents/${docId}`, {
multipart: {
title: doc.title,
documentDate: '1945-05-08',
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
data: {
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.1,
text: 'Seeded line',
label: null
}
});
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
const block = await blockRes.json();
annotationId = block.annotationId;
const commentRes = await request.post(
`/api/documents/${docId}/transcription-blocks/${block.id}/comments`,
{
data: { content: 'Target comment for deep-link test' }
}
);
if (!commentRes.ok()) throw new Error(`Create comment failed: ${commentRes.status()}`);
const comment = await commentRes.json();
commentId = comment.id;
});
async function openDeepLink(page: Page) {
const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`;
await page.goto(url);
await page.waitForSelector('[data-hydrated]');
}
for (const viewport of [
{ width: 320, height: 700, name: 'mobile-320' },
{ width: 1440, height: 900, name: 'desktop-1440' }
]) {
test(`deep-link scrolls comment into view at ${viewport.name}`, async ({ page }) => {
test.setTimeout(45_000);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await openDeepLink(page);
// Transcribe mode was auto-entered — Fertig button is visible
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible({ timeout: 15_000 });
// The target comment article is in the DOM and visible
const article = page.locator(`#comment-${commentId}`);
await expect(article).toBeVisible({ timeout: 10_000 });
// URL query params are stripped after handling
await expect.poll(() => page.url()).not.toContain('commentId=');
await expect.poll(() => page.url()).not.toContain('annotationId=');
await page.screenshot({
path: `test-results/e2e/notification-deep-link-${viewport.name}.png`
});
});
}
test('axe accessibility check passes on document detail with deep-link', async ({ page }) => {
test.setTimeout(45_000);
await openDeepLink(page);
await expect(page.locator(`#comment-${commentId}`)).toBeVisible({ timeout: 10_000 });
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toHaveLength(0);
});
});

View File

@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
}
test.describe('Richtlinien page — content', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/hilfe/transkription');
});
test('renders h1 title, intro, five rules, four chips, closing card', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: /Transkriptions-Richtlinien/ })
).toBeVisible();
await expect(page.getByText(/Damit alle Briefe einheitlich/)).toBeVisible();
await expect(page.getByText('Nicht lesbare Wörter')).toBeVisible();
await expect(page.getByText('Durchgestrichene Wörter')).toBeVisible();
await expect(page.getByText(/Das lange s/)).toBeVisible();
await expect(page.getByText('Unsichere Namen')).toBeVisible();
await expect(page.getByText(/Dialekt/)).toBeVisible();
await expect(page.getByText('Abkürzungen')).toBeVisible();
await expect(page.getByText('Datumsformate')).toBeVisible();
await expect(page.getByText(/Fehlt eine Regel/)).toBeVisible();
});
test('Wikipedia link opens in new tab with annotation', async ({ page }) => {
const wikiLink = page.getByRole('link', { name: /Wikipedia/ });
await expect(wikiLink).toHaveAttribute('target', '_blank');
await expect(wikiLink).toHaveAttribute('rel', 'noopener noreferrer');
await expect(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer');
await expect(wikiLink).toContainText(/öffnet in neuem Tab/);
});
});
test.describe('Richtlinien page — accessibility', () => {
for (const viewport of [320, 768, 1440]) {
test(`axe: light theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
await page.setViewportSize({ width: viewport, height: 800 });
await page.goto('/hilfe/transkription');
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
});
test(`axe: dark theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
await page.setViewportSize({ width: viewport, height: 800 });
await page.goto('/hilfe/transkription');
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
});
}
});
test.describe('Richtlinien page — print media', () => {
test('print snapshot hides nav, annotation chip, and new-tab spans', async ({ page }) => {
await page.emulateMedia({ media: 'print' });
await page.goto('/hilfe/transkription');
const nav = page.locator('.app-nav');
if ((await nav.count()) > 0) {
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

@@ -0,0 +1,105 @@
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']);
}
test.describe('Transcribe coach — empty state', () => {
let docId: string;
test.beforeAll(async ({ request }) => {
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.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
timeout: 5000
});
await expect(page.getByText(/Kurrent-Erkenner lernt noch/)).toBeVisible();
await expect(page.getByText(/Rahmen ziehen/)).toBeVisible();
await expect(page.getByText(/Text eingeben/)).toBeVisible();
await expect(page.getByText(/Speichert automatisch/)).toBeVisible();
await expect(page.getByRole('img', { name: /Rahmen ziehen|Animation/i })).toBeVisible();
});
test('training footer is NOT visible on empty doc', async ({ page }) => {
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.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
timeout: 5000
});
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
});
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.goto(`/documents/${docId}`);
// Toggle dark theme
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
});
const a11y = await buildAxe(page).analyze();
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 });
});
});

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