Compare commits

...

242 Commits

Author SHA1 Message Date
Marcel
a65a55448e test: align unit stubs and fixtures with the review-round changes
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m16s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m17s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI caught three spots the targeted local runs missed: the relevance-path
unit tests still stubbed findAllById (the path now calls findByIdIn — the
batchMetadata stubs legitimately keep findAllById), the second
GeschichtenCard test file still expected the removed email fallback, and
the AND/OR-toggle describe lacked the wait-for-slide-transition guard its
sibling describe documents — the flake that failed run 2208.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:37:31 +02:00
Marcel
4374f75d3c chore(types): regen api.ts — AuthorSummary email removed from spec
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m6s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Failing after 4m2s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:57:31 +02:00
Marcel
f10b0cb73e fix(journey): editor review round — labels, errors, pending state, a11y, tests
Addresses the remaining #792 review blockers and concerns in the journey
editor cluster:

- Interlude rows show 'Zwischentext' (dedicated key), not the add-button text
- All four mutation handlers route the backend ErrorCode through
  getErrorMessage (a 409 duplicate no longer says 'bitte Seite neu laden')
  and console.error their failures so client-side errors leave a trace
- Remove implements the spec'd pending state: row stays dimmed with an
  aria-live 'wird entfernt…' until the DELETE resolves; failure keeps the row
- Move announcements fire after the reorder resolves (no false 'verschoben')
- Touch targets ≥44px (remove ×, note links, create submit); focus moves to
  the new row after add, to a sensible neighbor after remove, back to × on
  confirm-cancel; drag handle is pointer-only; title/intro get aria-labels;
  publish-disabled reason is a visible hint, not a title tooltip
- Amber warning styles use new --color-warning-* tokens with dark remaps
- Blocked interlude-clear restores the draft instead of showing phantom text
- useBlockDragDrop moves to $lib/shared/hooks — geschichte no longer imports
  another domain's internals
- Test hardening: reorder-failure rollback (non-ok + reject), publish/
  unpublish/empty-warning surface, destructive confirm path, maxlength
  assertions, JourneyCreate failure path, edit-page STORY/JOURNEY branch,
  fixture factory, m.* assertions, all fixed sleeps replaced with polling

67 component tests green across 6 spec files; transcription consumer of the
moved hook re-verified (30 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:55:12 +02:00
Marcel
7977d22d0b fix(picker): honest combobox — keyboard navigation, listbox roles, no-results state
The input declared role=combobox + aria-autocomplete=list while arrow keys
did nothing (WCAG 4.1.2). Wired the useTypeahead activeIndex the same way
PersonTypeahead does: ArrowUp/Down cycle, Enter/Space select, Escape closes,
aria-activedescendant tracks the active option; the list is a real listbox
with option roles again (the interim role downgrade is reverted). Zero hits
now render a 'Keine Treffer' row instead of silently vanishing, aria-expanded
matches the visible state, and the hook sets loading at setQuery so the
debounce window can't read as 'no results'. DocumentMultiSelect renders the
shared error state too. _uid counter replaced with $props.id().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:53:56 +02:00
Marcel
98e3d924e5 feat(geschichten): wire the ?documentId list filter the drawer already links to
DocumentMetadataDrawer links to /geschichten?documentId={id}, but the list
loader silently dropped the param — the user got the unfiltered list. The
loader now validates the UUID and forwards it to GET /api/geschichten,
returning it as documentIdFilter in page data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:36:54 +02:00
Marcel
c4606cef8b fix(journey): person chips rendered blank — share one PersonView→PersonOption projection
JourneyEditor fed GeschichteView.PersonView (no displayName) straight into
the displayName-rendering PersonMultiSelect, so every chip on a journey was
empty. The name mapping both editors need now lives once in
$lib/person/personOption.ts (with the [Unbekannt] fallback matching
GeschichteService.toView), and PersonMultiSelect/GeschichteSidebar import
the narrow PersonOption contract from there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:32:05 +02:00
Marcel
d6ac88a211 docs(adr): ADR-036 — geschichte responses are views, never entities
Captures the read-model invariant that was only living in a code comment:
views are assembled in-transaction, entities never cross the controller
boundary in the geschichte domain. CLAUDE.md §DTOs now names the exception
so the convention text stops contradicting the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:29:21 +02:00
Marcel
d34afb2298 fix(geschichte): store JOURNEY intros as plain text — no HTML entity encoding
The OWASP sanitizer entity-encodes ('&' → '&amp;') while JourneyReader
renders the intro via Svelte text interpolation — a curator typing
'Müller & Söhne' saw 'Müller &amp; Söhne', re-encoded cumulatively on every
editor round-trip. JOURNEY bodies now bypass the sanitizer (safe: the reader
never uses {@html}); STORY bodies keep the full allow-list sanitization.
This makes the code match the PR's documented design note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:28:09 +02:00
Marcel
e077dba595 fix(geschichte): stop exposing author email in the list projection
GET /api/geschichten shipped every author's AppUser email to all readers via
GeschichteSummary.AuthorSummary — contradicting the documented rule that
author projections never expose email or group memberships. The frontend
only used it as a display-name fallback; it now falls back to [Unbekannt],
matching the server-side rule in GeschichteService.toView.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:25:11 +02:00
Marcel
b3d54a12c4 feat(journeyitem): V74 — partial unique index + note-length CHECK as atomic backstops
The append dedup guard was check-then-insert: two concurrent appends of the
same document both pass exists() and both insert. The partial unique index
on (geschichte_id, document_id) WHERE document_id IS NOT NULL closes the
race; append saveAndFlush-es and maps the DataIntegrityViolationException to
the same 409 JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check. The
CHECK on note length pins the 2000-char contract in the schema, mirroring
chk_text_length on transcription_blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:21:41 +02:00
Marcel
02efe738a0 fix(geschichte): make the documented error-code contract real
GESCHICHTE_TYPE_IMMUTABLE and JOURNEY_NOTE_TOO_LONG were declared in
errors.ts, translated, and documented — but never existed in the backend.
update() now rejects a type change with 409 (omitted/same type still pass);
note length is enforced at 2000 with its own code, matching the frontend
maxlength and the i18n message (resolves the #793 discrepancy in favour of
the spec). JOURNEY_ITEM_NOT_IN_JOURNEY is deleted everywhere instead — the
deliberate 404 posture for cross-journey item ids must not leak existence
via a distinct code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:14:35 +02:00
Marcel
ae0b143685 fix(journeyitem): explicit JPQL for the document-dedup guard — derived query unmappable
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m30s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Failing after 3m46s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m11s
POST /api/geschichten/{id}/items with a documentId failed 500: Spring Data
resolved the derived existsByGeschichteIdAndDocumentId path as a direct
documentId attribute (shadowed by the transient getDocumentId() getter)
instead of document.id, producing JPQL Hibernate cannot map. Existing tests
only appended note items, so the document branch was never exercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:05:27 +02:00
Marcel
4e28f2f31b refactor(frontend): regen API types — migrate consumers off dropped Geschichte schema
With create/update returning GeschichteView, no endpoint serves the raw
Geschichte entity and springdoc drops its schema. Dashboard modules and the
home loader now use GeschichteSummary; GeschichteEditor takes GeschichteView
and maps persons into the displayName shape PersonMultiSelect renders —
fixing blank person chips on story edit. PersonMultiSelect/Sidebar narrow to
Pick<Person, 'id' | 'displayName'>, mirroring the DocumentOption precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:01:42 +02:00
Marcel
282c819e2a feat(geschichte): carry updatedAt in GeschichteSummary projection
The reader dashboard renders 'bearbeitet vor X' from updatedAt, but the
summary projection dropped the field when the list endpoint moved off the
entity — drafts have no publishedAt to fall back on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:01:09 +02:00
Marcel
6e006bafd0 fix(typeahead): surface search failures instead of an empty dropdown
The document picker parsed the response without checking r.ok, so a 401/500
rendered identically to 'no matches' and the dropdown silently vanished —
which is how the broken relevance path shipped invisibly. The fetch now
throws on non-OK, the useTypeahead hook exposes an error flag, and the
picker renders a visible failure message (de/en/es).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:53:29 +02:00
Marcel
e988c3eae7 fix(geschichte): return GeschichteView from create/update — kill write-path 500
PATCH /api/geschichten/{id} (save draft, publish) returned the raw entity;
with open-in-view false, Jackson serialized the lazy items collection after
the transaction closed and every save failed with LazyInitializationException.
Write methods now assemble GeschichteView in-transaction, completing the
read-model boundary already used by GET — entities no longer cross the
controller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:06:18 +02:00
Marcel
522d1f3ec9 fix(search): load relevance-path documents with Document.list entity graph
The pure-text RELEVANCE fast path loaded documents via plain findAllById,
which carries no entity graph. With Document.tags LAZY (ADR-022) and no
surrounding transaction, resolveDocumentTagColors hit the dead proxy and
every q-only search (document picker typeaheads) failed with 500
LazyInitializationException. Dedicated findByIdIn declares the same fetch
shape as the other search loaders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:58:13 +02:00
Marcel
44f15dd4a2 fix(geschichte): persist GeschichteType on create — JOURNEY was silently dropped
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
GeschichteUpdateDTO lacked a `type` field, so the `type: 'JOURNEY'` sent by
JourneyCreate was discarded by Jackson and every new Geschichte was saved as
STORY. The edit page branched on type, so journeys always showed the STORY
editor with no document-adding capability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:02:58 +02:00
Marcel
bd5e6e6fbe fix(test): narrow getByText selector to dropdown-only item in aria-disabled test
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m26s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m49s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:38:22 +02:00
Marcel
3e54b6e90a feat(journey): replace new-journey placeholder with JourneyCreate form
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m40s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:32:23 +02:00
Marcel
abe5d62102 fix(test): update JourneyEditor spec to use getByText after listbox role downgrade
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m41s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:21:58 +02:00
Marcel
ba394e1165 chore(i18n): delete unused journey_item_pending_add/remove keys
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m41s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m49s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:09:03 +02:00
Marcel
0130c4fa99 refactor(journey-editor): extract scheduleAnnounceReset; comment no-op items=prev in add handlers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:08:27 +02:00
Marcel
d689a66275 fix(i18n): add geschichte_sidebar_status key; replace hardcoded Status in GeschichteSidebar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:06:24 +02:00
Marcel
401371fd18 fix(a11y): raise inline remove confirm/cancel buttons to 44px touch target
px-2 py-1 gave ~28px height — half the WCAG 2.2 / project 44px minimum.
Changed to min-h-[44px] inline-flex items-center for both buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:01:06 +02:00
Marcel
33fe93a591 fix(a11y): raise interlude label contrast to WCAG AA
#6b7280 (gray-500) on #f5f4f0 yielded ~4.0:1 — below the 4.5:1 AA minimum
for normal text. Changed to #4b5563 (gray-600) which provides ~6.9:1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:00:31 +02:00
Marcel
d35a165881 refactor(document-picker): downgrade listbox ARIA roles; fix unique listboxId
role=listbox + role=option without arrow-key navigation is misleading — the
WAI-ARIA combobox pattern requires aria-activedescendant handling that isn't
implemented. Downgraded to plain <ul>/<li>; input keeps role=combobox +
aria-controls pointing to the list id.

listboxId was a module-level constant so two simultaneous instances would share
the same DOM id. Fixed with a <script module> counter.

Updated spec queries from getByRole('option') to getByText() — tests behaviour,
not the ARIA implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:59:30 +02:00
Marcel
7df640201a refactor(document): extract shared DocumentOption type and createDocumentTypeahead factory
DocumentPickerDropdown and DocumentMultiSelect had identical createTypeahead
configs, fetch logic, and formatDocLabel helpers. Extracted to
documentTypeahead.ts; all four consumers import from the shared module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:57:44 +02:00
Marcel
8adc39e2ce test(geschichte): add integration tests for documentId JPQL EXISTS filter
Verifies the EXISTS subquery in findSummaries actually filters by document
membership — a journey containing the document appears, one without does not.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:51:51 +02:00
Marcel
67861239f8 fix(search-filter-bar): wait for slide transition before clicking undated toggle
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m23s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
track_reactivity_loss in Svelte 5 async.js fires when a $bindable write
happens while the slide transition is still being tracked asynchronously.
Waiting for the toggle to be visible ensures the transition has settled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:09:35 +02:00
Marcel
b30c0d7f96 fix(journey-add-bar): replace aria-disabled with native disabled on confirm button
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m42s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
aria-disabled alone leaves the button keyboard-activatable, violating
WCAG 4.1.2. Native disabled removes it from the tab order and prevents
activation via Enter/Space.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:53:08 +02:00
Marcel
e1ca2c6831 test(journey-add-bar): add red test — confirm button must be natively disabled (WCAG 4.1.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:52:47 +02:00
Marcel
55989058a5 fix(journey-item-row): restore note state and show error when remove patch fails
handleNoteRemove mutated UI state optimistically without try/catch.
A failed PATCH left the note visually deleted while it survived on the
server. Now uses snapshot/rollback identical to handleNoteBlur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:52:15 +02:00
Marcel
1cfa03d1f0 test(journey-item-row): add red test — note remove must restore on patch failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:51:49 +02:00
Marcel
63bc24d2f1 fix(journey-editor): send {"note":null} on clear instead of omitting key
null ?? undefined evaluated to undefined, causing JSON.stringify to omit
the key entirely — the backend treated an absent note field as a no-op,
so clearing a note never persisted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:51:26 +02:00
Marcel
e5f276a164 test(journey-editor): add red test — PATCH body must send {"note":null} not {}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:50:58 +02:00
Marcel
949421a076 fix(geschichten): restore documentId filter via journey_items EXISTS subquery
The PR removed the documentId filter from list() along with the old
Geschichte.documents ManyToMany, but the document-detail page and its
frontend server still query GET /api/geschichten?documentId=<id> to show
related stories. Without the filter the endpoint silently returned every
published story. Restores the filter through a JPQL EXISTS check on
journey_items so only journeys that include the given document are returned.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:18:28 +02:00
Marcel
353945c952 fix(journey-item): enforce duplicate-document guard in append()
Adds JOURNEY_DOCUMENT_ALREADY_ADDED to ErrorCode, an
existsByGeschichteIdAndDocumentId() repo method, and a 409 guard in
JourneyItemService.append() — the error code was registered on the
frontend but never thrown on the backend, allowing concurrent tabs to
add the same document twice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:18:00 +02:00
Marcel
4572572c94 fix(journey-editor): clear liveAnnounce 500ms after move; document svelte-ignore suppressions
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m26s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m49s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
NVDA+Chrome and VoiceOver+Safari can re-announce a persistent non-empty
aria-live region when adjacent DOM mutations occur. Clearing with a
500ms delay gives the announcement time to fire once before going quiet.

The two svelte-ignore a11y_no_static_element_interactions suppressions are
given preceding comments explaining the keyboard accessibility contract so
they are not mistaken for unaddressed tech debt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:47:30 +02:00
Marcel
dfc065b727 test(journey-editor): verify liveAnnounce region clears after 500ms
A persistent non-empty aria-live region can cause stale re-announcements
on adjacent DOM mutations. This test confirms the region is empty after
the 500ms clear timeout fires following a move operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:47:04 +02:00
Marcel
ed4ef88598 test(journey-item-row): add note-patch rejection error-state test
The noteError alert path (role=alert paragraph) was untested.
The catch block in handleNoteBlur was already implemented; this
test verifies it renders the alert when onNotePatch rejects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:46:30 +02:00
Marcel
65e196292e refactor(journey-editor): fix fragile ordering assertion with compareDocumentPosition
The previous check used document.body.textContent which includes ARIA labels
and hidden elements — a match in those could misfire the indexOf comparison.
compareDocumentPosition(DOCUMENT_POSITION_FOLLOWING) checks DOM tree position
directly and is not affected by non-visible text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:46:06 +02:00
Marcel
afe7e5586a docs(architecture): update C4 geschichtenEdit for JourneyEditor
Replace the '#753 deferred' placeholder with the actual implementation:
JOURNEY edit route now opens JourneyEditor (item list, drag/move-up/down,
add bar, sidebar) while STORY route continues to use GeschichteEditor.
Add item-mutation endpoints to the relation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:31:56 +02:00
Marcel
53f259243e docs(glossary): add Interlude/Zwischentext entry
Interlude is a first-class editorial concept introduced by the JourneyEditor
(#753): a note-only JourneyItem with no backing document, stored in the note
column and distinguished visually by --color-interlude-* CSS tokens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:31:34 +02:00
Marcel
db4a5c0028 fix(journey-item-row): update JourneyItemRow spec to match new aria-label
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
The 'remove confirm' tests were still finding the remove button by the
old label ('Wirklich entfernen?'). After the aria-label change the
button is named 'Eintrag entfernen'; the visible confirmation text
'Wirklich entfernen?' in the DOM is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:07:47 +02:00
Marcel
1b2776aa89 docs: document 4 new journey/geschichte ErrorCodes in CLAUDE.md and ARCHITECTURE.md
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m37s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m49s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Both error-handling sections now list JOURNEY_ITEM_NOT_IN_JOURNEY,
JOURNEY_NOTE_TOO_LONG, JOURNEY_DOCUMENT_ALREADY_ADDED, and
GESCHICHTE_TYPE_IMMUTABLE alongside the existing security codes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:46:49 +02:00
Marcel
8df5b77189 fix(journey-add-bar): move vi.unstubAllGlobals into afterEach
Removed the inline vi.unstubAllGlobals() call from the end of the
'reveals picker' test body and added it to the shared afterEach hook
so every test in the file gets the same global cleanup regardless of
which test runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:46:28 +02:00
Marcel
06b8c99ce7 test(journey-editor): add reorder tests, fix vacuous isDirty test, fix remove btn name
- Add move-up and move-down tests that verify PUT /items/reorder is called
  with the swapped ID order; 50ms delay accounts for two await levels before
  csrfFetch is called (click → handleMoveUp → handleReorder → csrfFetch)
- Replace vacuous 'isDirty stays false' test (was asserting a dialog that
  never renders) with a meaningful publish-button-enabled assertion after
  adding an item
- Update remove button query from 'Wirklich entfernen?' to 'Eintrag entfernen'
  to match the new journey_remove_item_aria aria-label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:46:08 +02:00
Marcel
39e07fc02e fix(document-picker): add aria-label to combobox input
The document search input had no accessible label — role="combobox"
without a label is an accessibility violation. Bound aria-label to
the existing placeholder prop so screen readers announce the field purpose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:45:33 +02:00
Marcel
7e6030a4fc fix(journey-item-row): add journey_remove_item_aria key and fix remove button label
The remove button was using the confirmation-question text as its
aria-label. Added a new dedicated journey_remove_item_aria key
in all three locales so the button has a clear accessible name
before the confirmation dialog opens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:45:13 +02:00
Marcel
dd917460b0 fix(journey-item-row): raise move buttons to WCAG 2.2 44px touch target
Both move-up and move-down buttons had inline style="min-height: 22px"
which is below the WCAG 2.2 success criterion 2.5.8 (44×44 CSS pixels
minimum). Replaced with Tailwind min-h-[44px] min-w-[44px] classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:44:31 +02:00
Marcel
573e5c43d7 fix(journey-editor): render empty-state message when items list is empty
Previously the item list area was blank when no items had been added.
The empty-state paragraph uses the existing journey_empty_state i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:44:07 +02:00
Marcel
a5754162ce fix(journey-editor): add required params to m.journey_item_moved() calls
The screen-reader live announcement was calling m.journey_item_moved()
without the required {position, total, newPosition} parameters, which
the i18n template uses to build the full announcement string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:43:42 +02:00
Marcel
ddcf61cc5e fix(tests): resolve 9 CI test failures in journey-editor specs
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m29s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
- useBlockDragDrop: add runtime expect() alongside expectTypeOf so
  browser-mode runner counts at least one assertion
- JourneyAddBar: use exact:true on 'Hinzufügen' button — partial match
  was hitting '+ Brief hinzufügen' and '+ Zwischentext hinzufügen' too
- JourneyEditor: fix 4 issues — drop wrong not.toBeInTheDocument()
  (placeholder creates accessible name); pass title:'' in publish-disabled
  test (default was non-empty); use getByPlaceholder for interlude
  textarea to avoid 4-element strict-mode violation; exact:true for
  'Hinzufügen' button
- DocumentPickerDropdown: use .click({force:true}) on aria-disabled
  option — userEvent refuses non-enabled elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:20:35 +02:00
Marcel
1f9107b620 docs(journey-editor): update README and strike stale spec references
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m38s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Add JourneyEditor, JourneyItemRow, JourneyAddBar, GeschichteSidebar to the
geschichte README props table. Strike @dnd-kit/svelte-dnd-action library refs
and raw orange-*/blue-600 color classes in the editor spec HTML.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:59:58 +02:00
Marcel
ae0cb93a9e feat(journey-editor): branch edit page on geschichte type
Static imports for both editors; type-aware <h1> title; JOURNEY type routes
to JourneyEditor, STORY type continues to GeschichteEditor unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:57:44 +02:00
Marcel
a17eec537f feat(journey-editor): build JourneyEditor orchestrator
Main editing surface for JOURNEY-type Geschichten. Manages sorted item list
with optimistic add/remove/reorder (rollback on failure), drag-and-drop reorder
via createBlockDragDrop, intro textarea, and sidebar via GeschichteSidebar.
Publish requires at least one item + non-empty title.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:55:39 +02:00
Marcel
9a178210fa feat(journey-editor): build JourneyAddBar with document picker and interlude draft
Two add buttons: document picker (DocumentPickerDropdown) and interlude inline
draft form. Interlude confirm is aria-disabled until text is non-empty. Closing
one panel opens the other. Tests cover all three plan test cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:50:36 +02:00
Marcel
d88cde06a0 feat(journey-editor): build JourneyItemRow with note editing and remove confirm
Item row with drag handle, move-up/down buttons, inline note textarea (PATCH
on blur), interlude visual treatment, and inline confirm for removes that
would discard a note. Interlude note cannot be cleared (blocked on empty).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:48:57 +02:00
Marcel
65d241f69e feat(journey-editor): build DocumentPickerDropdown + refactor DocumentMultiSelect
New DocumentPickerDropdown: single-select document search with aria-disabled
for already-added items and sr-only "bereits enthalten" hint. DocumentMultiSelect
refactored to use createTypeahead, removing raw setTimeout/debounceTimer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:43:47 +02:00
Marcel
a619f950a5 feat(journey-editor): add i18n keys, error codes, and interlude CSS tokens
All 30+ journey_* message keys added to de/en/es.json. Four new ErrorCode
values for journey item operations wired into errors.ts + getErrorMessage().
Interlude CSS primitives (--c-interlude-bg/border/label) defined for light
and dark themes so JourneyItemRow can reference them via semantic aliases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:39:01 +02:00
Marcel
65b79a337b refactor(geschichte): extract GeschichteSidebar.svelte from GeschichteEditor
Moves Status + Persons sections into a shared component so both
GeschichteEditor (STORY) and the upcoming JourneyEditor (JOURNEY) can
use the same sidebar without duplicating markup. Adds <details> mobile
collapsibles with 44px summary hit areas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:33:05 +02:00
Marcel
4de664f4f6 refactor(dragdrop): generalize createBlockDragDrop<T extends { id: string }>
Removes the hard-typed TranscriptionBlockData constraint so JourneyEditor
can reuse the pointer-drag module without importing transcription types.
Selector contract (data-block-wrapper / data-drag-handle) unchanged.
Adds type-regression guard test verified via tsc --noEmit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:30:29 +02:00
Marcel
bee055e615 chore(merge): resolve api.ts conflict with feat/issue-750-lesereisen-data-model
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Drop stale JourneyItem/JourneyItemCreateDTO schemas — removed in base
branch when api.ts was regenerated; neither type is referenced in
frontend code (JourneyItemView is the read model used instead).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:07:29 +02:00
Marcel
9be24f2613 fix(tests): resolve 43 regressions caused by layout.css import in test-setup
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Importing layout.css in test-setup.ts activated Tailwind's responsive
breakpoint classes (hidden lg:flex, hidden md:block, etc.), making
42 elements invisible at the default narrow Playwright test viewport.

Revert the CSS import. Instead, add inline style attributes to the three
components whose tests measure computed properties (min-height, font-size)
— these values match what the Tailwind classes produce, so the real app
appearance is unchanged.

Also fix goto mock leakage in the geschichten/[id] delete-failure test:
the delete-success test's goto('/geschichten') call was not cleared before
the failure test ran. Add beforeEach(vi.clearAllMocks) to reset mock state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:53:20 +02:00
Marcel
d5441d3e55 fix(tests): resolve 10 failing browser-mode tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 6m5s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m55s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- Import layout.css in test-setup so Tailwind utilities (text-xs,
  min-h-[44px]) apply in vitest-browser — fixes computed-style assertions
  for badge font-size and touch-target height
- radioGroupNav: write aria-checked directly on radio buttons on arrow-key
  navigation, not only via the optional onChangeFn callback
- DashboardNeedsMetadata spec: tighten footer-link matcher from /50/ to
  /Alle 50/ — avoids strict-mode collision with row link whose relative
  time text also contains "50" (uploadedAt is exactly 50 days ago today)
- geschichten/[id] page spec: add missing await on userEvent.click before
  confirmService.settle() in both delete tests
- TypeSelector spec: replace storyCard.focus() (not on vitest-browser
  Locator) with userEvent.click(); force-dispatch aria-disabled Weiter
  click via element.click() to bypass Playwright actionability check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:36:56 +02:00
Marcel
c131507e30 docs(c4): update l3-frontend-3c-people-stories for STORY/JOURNEY dispatch
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m4s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m12s
geschichten components now describe the type-based reader split
(StoryReader / JourneyReader / JourneyItemCard / JourneyInterlude),
the TypeSelector creation flow, and the full set of API endpoints
(including DELETE /api/geschichten/{id} and GET /api/persons/{id}
for person pre-population).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:14:30 +02:00
Marcel
c50f04bafa refactor(geschichte): use formatPublishedAt() in GeschichteListRow — remove DRY violation
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m2s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m59s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
The inline publishedAt $derived.by() duplicated the exact logic that
formatPublishedAt() in utils.ts encapsulates. Replace it with the
shared helper and drop the now-unused formatDate import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:09:53 +02:00
Marcel
f004b1f2a6 fix(a11y): add role="note" to JourneyInterlude so aria-label is announced
Without a landmark or widget role, aria-label on a generic <div> is
silently ignored by most screen readers (ARIA spec). Adding role="note"
gives the element an ARIA role that accepts an accessible name, making
the interlude label actually announced.

Also adds a test asserting role="note" and the matching aria-label are
both present on the same element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:09:30 +02:00
Marcel
75de56928e test(storyreader): verify person chip link meets 44px touch-target height
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m57s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Mirrors the getBoundingClientRect pattern from JourneyItemCard.svelte.spec.ts.
Tests actual rendered height rather than presence of a CSS class string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:06:49 +02:00
Marcel
6ed8ecf571 feat(a11y): add aria-describedby to Weiter button when aria-disabled
Screen readers now announce the hint paragraph text on focus when no type
is selected, so users hear why the button is disabled without having to
click it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:06:11 +02:00
Marcel
4c75680977 refactor(radiogroupnav): remove aria-checked setAttribute calls
The action was writing aria-checked directly and then firing onChange,
which also triggered Svelte's own aria-checked={selected === type} binding.
Double-ownership: action now only calls focus() + onChange(value);
Svelte owns the attribute update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:05:38 +02:00
Marcel
930f69e884 refactor(geschichte): remove JSDoc what-comments from utils.ts
Function names already communicate intent. Comments that restate the
function name add noise without explaining why.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:05:07 +02:00
Marcel
eea8e6bf5a docs(journeyitemcard): document why item.document! non-null assertion is safe
JourneyReader filters items to only those where document != null before
passing them here — the ! assertion is valid by caller invariant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:04:32 +02:00
Marcel
55e3e4c531 fix(a11y): darken journey badge text from #b46820 to #7a3f0e for WCAG AA
Previous #b46820 on #fef0e6 = 3.81:1 — fails 4.5:1 required for text-xs
(12px normal text). #7a3f0e on #fef0e6 = 7.4:1 — passes WCAG AAA.
Dark-mode #e8862a on #3a2a1a = 5.16:1 — already passing, unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:04:04 +02:00
Marcel
7a5c2d0ba3 fix(geschichte): handle DELETE failure — show inline error on non-ok response
Adds deleteError $state to [id]/+page.svelte, parses backend error via
parseBackendError/getErrorMessage on !res.ok, and displays a role=alert
paragraph. Adds two browser-tier tests: success path (goto called) and
error path (alert visible, goto not called).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:03:04 +02:00
Marcel
994772564a fix(geschichten-new): add request to makeEvent and vi.fn wrapper to createApiClient mock
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
Sentry's wrapLoadWithSentry reads event.request.method — the test's makeEvent
now provides a real Request object. createApiClient mock was a plain function;
wrapping with vi.fn() enables vi.mocked(...).mockReturnValue in individual tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:25:56 +02:00
Marcel
a0930b62b0 test(typeselector): add keyboard navigation tests for ArrowRight/ArrowLeft
Verifies radioGroupNav action moves selection forward and wraps backward
so keyboard users can navigate the STORY/JOURNEY cards without a mouse.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:25:34 +02:00
Marcel
3572de487a test(journeyitemcard): use getBoundingClientRect for 44px touch-target assertion
CSS class string assertion was fragile — class names can change without
breaking the actual layout. DOM measurement via getBoundingClientRect is the
correct way to verify computed height meets WCAG 2.2 minimum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:25:12 +02:00
Marcel
f9cdc02a77 test(geschichte): add unit tests for formatAuthorName, formatAuthorDisplayName, formatPublishedAt
13 tests covering null/undefined inputs, partial names, email fallback,
and TZ-safe date slicing for formatPublishedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:24:52 +02:00
Marcel
4c24bbb002 refactor(geschichte): extract delete handler to [id]/+page.svelte, pass via ondelete prop
Moves the confirm-then-delete flow out of StoryReader and JourneyReader into
the single [id]/+page.svelte owner. Both reader components gain an optional
ondelete prop — the delete button calls ondelete?.() so the handler is opt-in
and never duplicated. Tests verify the prop is called on click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:24:33 +02:00
Marcel
91d9dae6fd refactor(geschichtelistrow): use formatAuthorName utility, eliminate inline name computation
Replaces the 3-line inline join with the shared formatAuthorName helper from
utils.ts. Test switches from CSS class string assertion to getComputedStyle
for the badge font-size check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:24:10 +02:00
Marcel
4184d0775b fix(journeyinterlude): use i18n aria-label instead of hardcoded German
Replaces aria-label="Kuratorennotiz" with m.journey_interlude_aria_label()
so screen readers get the correct label in all three supported locales.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:23:46 +02:00
Marcel
97026fec11 refactor(geschichte): add utils.ts (formatAuthorName/DisplayName/PublishedAt), update README
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m14s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:59:03 +02:00
Marcel
565eddd743 feat(lesereisen): TypeSelector (roving tabindex, aria-disabled), StoryCreate, type-gated new page, list uses GeschichteListRow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:58:40 +02:00
Marcel
0b9e8c2abb feat(lesereisen): JourneyItemCard, JourneyInterlude, JourneyReader with XSS + omit-rule specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:58:15 +02:00
Marcel
8a6bc27979 feat(lesereisen): StoryReader — extract body/persons/docs/actions, isJourney badge in detail header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:57:51 +02:00
Marcel
8fea94cb61 test(lesereisen): TDD red — tighten factories, add journey/selector/ssr tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:57:28 +02:00
Marcel
0d47bcb4a1 feat(lesereisen): GeschichteListRow with JOURNEY badge + i18n keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:44:21 +02:00
Marcel
825a622413 feat(lesereisen): add journey orange CSS tokens to all three theme blocks
--c-journey-bg/text/border wired in light :root, dark @media, dark [data-theme]
blocks. Exposed via @theme inline as color-journey-tint/journey/journey-border.
Light: #B46820 on #FEF0E6 ≈ 4.6:1 AA at 12px bold. Dark: #E8862A on #3A2A1A ≈ 4.7:1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:33:08 +02:00
Marcel
81a12ba35c feat(api): regenerate api.ts — GeschichteView, GeschichteSummary, JourneyItemView, DocumentSummary
Self-check: GeschichteView.items present; type emitted as 'STORY'|'JOURNEY' union literal.
List endpoint returns GeschichteSummary[]; detail endpoint returns GeschichteView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:31:47 +02:00
0780c09bb4 feat(geschichte): JourneyItem CRUD API — append, updateNote, delete, reorder (#751) (#788)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
## Summary

Implements the backend JourneyItem CRUD API on top of the data model from #750, building towards the full Lesereisen feature (#751).

**Completed in this PR:**
- `jackson-databind-nullable` 0.2.6 + `JacksonConfig` (`@Bean Module`) for three-way PATCH semantics (`JsonNullable`)
- `AuditKind`: `JOURNEY_ITEM_ADDED`, `JOURNEY_ITEM_REMOVED`, `JOURNEY_ITEMS_REORDERED` (last is rollup-eligible)
- `ErrorCode`: `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT`
- V73 migration: `UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED` + `CHECK (position > 0)` on `journey_items`
- `JourneyItemConstraintsTest`: verifies deferrable flag via `pg_constraint` query; position check; duplicate position rejection (3 passing tests)
- Read models: `DocumentSummary`, `JourneyItemView`, `GeschichteView` (with `AuthorView` to prevent AppUser email leak)
- `DocumentService.getSummaryById` — lean lookup without tag-color resolution
- `JourneyItemRepository`: extended with `findByGeschichteIdOrderByPosition`, `findByIdAndGeschichteId` (IDOR-safe), `findIdsByGeschichteId`, `findMaxPositionByGeschichteId`, `countByGeschichteId`
- DTOs: `JourneyItemCreateDTO`, `JourneyItemUpdateDTO` (`JsonNullable<String> note`), `JourneyReorderDTO`

**Still in progress (WIP):**
- `JourneyItemService` — `append`, `updateNote`, `delete`, `reorder`, `toSummary`, `toView` (Task 6)
- `GeschichteService.getById` → returns `GeschichteView` (Task 7)
- New endpoints on `GeschichteController` + slice tests (Task 8)
- Frontend error codes + i18n + `npm run generate:api` (Task 9)

## Commits

- `0b177247` feat(config): add jackson-databind-nullable for JsonNullable PATCH DTO support
- `408ae334` feat(audit,error): add JourneyItem AuditKind values and ErrorCodes
- `7b06c3ad` feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items
- `160ca1c3` feat(geschichte): add DocumentSummary, JourneyItemView, GeschichteView read models
- `2ad5c36e` feat(geschichte): extend JourneyItemRepository and add item DTOs

## Test plan

- [ ] `./mvnw test -Dtest=JourneyItemConstraintsTest` — all 3 constraint tests pass
- [ ] `./mvnw clean package -DskipTests` — builds clean after remaining tasks are merged
- [ ] Frontend: `npm run generate:api` after Task 9 endpoint additions

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #788
2026-06-08 22:15:10 +02:00
Marcel
77cbbd34a0 test(journeyitem): verify findSummaryByIdInternal never called before JOURNEY-type guard
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:38:25 +02:00
Marcel
84b47f1836 fix(geschichte): move DocumentSummary to journeyitem sub-package
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:37:07 +02:00
Marcel
99111273e5 refactor(document): rename getSummaryById to findSummaryByIdInternal to signal scope-check bypass
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
The method intentionally skips permission checks and tag-colour resolution.
Renaming it to findSummaryByIdInternal makes the internal-only contract
visible at every call site, closing the latent CWE-284 risk flagged in
the PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:13:35 +02:00
Marcel
f09c79744e fix(geschichte): restore getView() on GeschichteService with @Transactional(readOnly=true) — fixes two-call transaction gap
Re-inject JourneyItemService into GeschichteService (no cycle:
JourneyItemService → GeschichteQueryService, not GeschichteService).
Add getView(UUID) that loads the Geschichte and its items in a single
@Transactional(readOnly=true) session. Controller now delegates to
getView() instead of making two separate service calls. Tests updated
to stub getView() and cover the new method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:12:25 +02:00
Marcel
1108277472 refactor(geschichte): extract PersonNameFormatter to eliminate duplicated name-join logic
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m51s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
Create PersonNameFormatter with a single static join(firstName, lastName) method.
Replace the inline string concatenation in GeschichteService.toView() and the
private join() method in JourneyItemService with calls to PersonNameFormatter.join().
The new helper handles null-safety and trimming consistently in one place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:53:49 +02:00
Marcel
9db3b41fdb docs(api): document reorder full-list contract in OpenAPI
Add @Operation annotation to reorderItems() clarifying that itemIds must
contain ALL item IDs for the journey in the desired order — a partial list
returns 400 Bad Request. This surfaces the contract in the generated
OpenAPI spec and Swagger UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:52:02 +02:00
Marcel
73004ce49f docs(document): document scope-check bypass on getSummaryById
Clarify in the Javadoc that getSummaryById intentionally skips scope checks
and tag-colour resolution. This is safe under the current single-tenant model
and is explicitly used by JourneyItemService.append() to validate that a linked
document exists before persisting a JourneyItem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:51:10 +02:00
Marcel
c31f82a69c fix(test): use nullValue() matcher instead of doesNotExist() for null note field
doesNotExist() asserts the key is absent from the JSON object, but Jackson
serializes a null Optional<String> as {"note": null} — the key is present with
a null value. nullValue() correctly matches that case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:50:17 +02:00
Marcel
f9ae6a91ba test(journeyitem): add integration tests for append and reorder against real PostgreSQL
Add two service-level integration tests to JourneyItemIntegrationTest:
- append_persists_item_at_position_10: verifies that the first append to an
  empty journey creates an item at position 10 in the DB.
- reorder_swaps_positions_atomically: appends two items then reorders them,
  asserting the DB reflects the new position assignment.
Both tests use the SecurityContextHolder authentication pattern from
GeschichteServiceIntegrationTest and mock S3Client to avoid MinIO connections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:49:23 +02:00
Marcel
70da532f54 docs(c4): add GeschichteQueryService component; fix GeschichteService relationship
Add GeschichteQueryService component to the L3 supporting-domains diagram.
Remove the now-deleted Rel(geschSvc, journeyItemSvc, "Delegates getItems()")
arrow and add the correct Rel(journeyItemSvc, geschQuerySvc, ...) arrow that
reflects the actual dependency direction after the refactor in the prior commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:47:46 +02:00
Marcel
ad90ae75bf fix(journeyitem): use JOIN FETCH to eliminate N+1 document queries
Add findByGeschichteIdWithDocument() to JourneyItemRepository with a
LEFT JOIN FETCH on document. getItems() now uses this query so that all
documents for a journey's items are loaded in a single SQL round-trip.
toView() now reads item.getDocument() directly from the already-fetched
association instead of issuing a separate documentService.getSummaryById()
call per item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:46:54 +02:00
Marcel
164178ecf1 refactor(geschichte): assemble GeschichteView in controller — break GeschichteService↔JourneyItemService cycle
GeschichteService.getById() now returns the Geschichte entity (with the
DRAFT visibility guard intact). The controller calls journeyItemService.getItems()
and geschichteService.toView() to assemble the GeschichteView, removing the
need for GeschichteService to hold a reference to JourneyItemService.
Tests updated accordingly: GeschichteServiceTest tests toView() directly;
GeschichteControllerTest stubs both service calls; integration test uses the
two-step pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:45:09 +02:00
Marcel
3f36d2a7f1 chore(test): remove JacksonConfig from GeschichteControllerTest @Import
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
JacksonConfig was deleted (empty placeholder) — remove the now-stale
import and @Import reference from the controller slice test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:07:03 +02:00
Marcel
5b2ee31292 feat(i18n): add journey_item_document_deleted placeholder key
Adds de/en/es translations for the case where a JourneyItem's linked
document has been deleted (document field is null), so the UI PR can
display a meaningful fallback string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:06:09 +02:00
Marcel
3d80bc656c refactor(journeyitem): use saveAll in reorder for efficiency
Replace the per-item save() loop in reorder() with a single
saveAll() call, reducing database round-trips for large journeys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:05:28 +02:00
Marcel
4a0fed617a refactor(geschichte): route all reads through GeschichteQueryService
JourneyItemService no longer injects GeschichteRepository directly.
GeschichteQueryService gains findById() so JourneyItemService.append()
can load the Geschichte entity via the service layer, satisfying the
cross-domain layering rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:04:21 +02:00
Marcel
7ba6342a84 chore(config): remove empty JacksonConfig placeholder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:02:51 +02:00
Marcel
598ad622e7 fix(journeyitem): use specific error codes in append() — JOURNEY_AT_CAPACITY and GESCHICHTE_TYPE_MISMATCH
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
- JourneyItemService.append(): replace VALIDATION_ERROR with GESCHICHTE_TYPE_MISMATCH (409 conflict)
  for non-JOURNEY type guard and JOURNEY_AT_CAPACITY (409 conflict) for 100-item cap
- JourneyItemServiceTest: update assertions to expect the new specific error codes
- CLAUDE.md: expand geschichte/ package table entry with GeschichteQueryService and journeyitem/ sub-domain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:31:30 +02:00
Marcel
c5611250ec test(journey): rename updateItemNote test to clarify Optional deserialization semantics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:17:25 +02:00
Marcel
e400b1d77e feat(error): add GESCHICHTE_TYPE_MISMATCH error code with i18n (de/en/es)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:45 +02:00
Marcel
1fb0c41216 feat(error): add JOURNEY_AT_CAPACITY error code with i18n (de/en/es)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:13:47 +02:00
Marcel
147aa56386 feat(audit): add JOURNEY_ITEM_NOTE_UPDATED audit kind and wire into updateNote()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:04:43 +02:00
Marcel
7c06609816 refactor(journey): make toView() and toSummary() package-private
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:03:24 +02:00
Marcel
2ae1bb3a30 fix(journey): reorder() throws 404 when Geschichte does not exist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:02:03 +02:00
Marcel
69db198319 refactor(geschichte): introduce GeschichteQueryService with existsById()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:00:32 +02:00
Marcel
e157d90b53 docs(backend): add JourneyItemService and GeschichteQueryService to CLAUDE.md package table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:58:20 +02:00
Marcel
97f22e1ce8 fix(review): friendlier i18n message for journey position conflict error
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:13:39 +02:00
Marcel
5539158e8f fix(review): add JourneyItemService to C4 L3 supporting-domains diagram
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:12:50 +02:00
Marcel
7ed0032661 fix(review): replace Set<Person> with Set<PersonView> in GeschichteView — prevents leaking admin fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:12:13 +02:00
Marcel
2f471155b8 fix(review): replace email fallback with [Unbekannt] in AuthorView — prevents CWE-359 leak
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:09:40 +02:00
Marcel
4eb6abd920 fix(review): address PR #788 review blockers
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m26s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m53s
CI / fail2ban Regex (pull_request) Failing after 46s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
- GlobalExceptionHandler maps uq_journey_items_geschichte_position constraint
  violation to HTTP 409 JOURNEY_ITEM_POSITION_CONFLICT
- JourneyItemService.reorder() rejects duplicate IDs before set-equality check
  to prevent silent position overwrite
- JourneyItemRepository removes orphaned findAllByGeschichteId method
- GeschichteView removes stale com.fasterxml.jackson import
- Tests: add appendItem_returns409_on_position_conflict (controller),
  reorder_returns400_when_itemIds_contain_duplicates (service)
- Fix JourneyItemIntegrationTest compilation after repository cleanup
- db-orm.puml annotates journey_items position with CHECK + UNIQUE DEFERRABLE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:16:32 +02:00
Marcel
6fc5ce6ddd docs: update GLOSSARY for JourneyItem view types; add ADR-035
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Fixes GLOSSARY position-step value (1000→10), adds DEFERRABLE constraint note,
and documents GeschichteView, JourneyItemView, and DocumentSummary read-model types.

ADR-035 records the decision to use Optional<String> for three-way PATCH semantics
instead of jackson-databind-nullable (which targets Jackson 2.x and is incompatible
with Spring Boot 4.0 / Jackson 3.x).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:06:25 +02:00
Marcel
4603e335fd feat(frontend): add JOURNEY_ITEM error codes, i18n keys, regen API types
Adds JOURNEY_ITEM_NOT_FOUND and JOURNEY_ITEM_POSITION_CONFLICT to the frontend
ErrorCode union and getErrorMessage() switch. Adds de/en/es translations.
Regenerates api.ts from the current OpenAPI spec (needs a second run once the
backend is restarted with the new endpoints compiled in).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:04:51 +02:00
Marcel
a0fa8f4d02 feat(journeyitem): add CRUD endpoints for JourneyItems on GeschichteController
Adds POST/PATCH/DELETE/PUT-reorder endpoints for journey items, backed by
JourneyItemService. Replaces jackson-databind-nullable (Jackson 2.x, incompatible
with Spring Boot 4 / Jackson 3.x) with Optional<String> three-way PATCH semantics:
null = absent/no-op, empty = clear, present = set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:02:12 +02:00
Marcel
d29f217328 feat(geschichte): getById returns GeschichteView; author email never exposed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:52:29 +02:00
Marcel
fdc9273c86 feat(geschichte): implement JourneyItemService — append, updateNote, delete, reorder (35 unit tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:47:42 +02:00
Marcel
2ad5c36e3c feat(geschichte): extend JourneyItemRepository and add item DTOs
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m59s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Repository: findByIdAndGeschichteId (IDOR-safe lookup),
findByGeschichteIdOrderByPosition, findIdsByGeschichteId (Set<UUID> for
set-equality reorder check), findMaxPositionByGeschichteId, countByGeschichteId.
DTOs: JourneyItemCreateDTO (documentId+note), JourneyItemUpdateDTO
(JsonNullable<String> note for 3-way PATCH), JourneyReorderDTO (List<UUID>).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:32:50 +02:00
Marcel
160ca1c3e9 feat(geschichte): add DocumentSummary, JourneyItemView, GeschichteView read models
DocumentSummary: lean document projection for journey item embedding —
skips tag-color resolution (getSummaryById), includes receiverCount
(0 when no receivers, non-null). JourneyItemView: response record for
item CRUD and GET. GeschichteView: detail response with summarised
author {id, displayName} to prevent AppUser email/group leak.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:31:40 +02:00
Marcel
7b06c3adec feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items
DEFERRABLE INITIALLY DEFERRED allows mid-transaction position swaps
during reorder (checked at COMMIT, not per-row). CHECK (position > 0)
guards against off-by-one in the append path. Both verified by
JourneyItemConstraintsTest via raw pg_constraint query + jdbcTemplate
inserts against a real postgres:16-alpine container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:30:07 +02:00
Marcel
408ae3345c feat(audit,error): add JourneyItem AuditKind values and ErrorCodes
Adds JOURNEY_ITEM_ADDED, JOURNEY_ITEM_REMOVED, JOURNEY_ITEMS_REORDERED
(last is ROLLUP_ELIGIBLE — drag-heavy editing produces many events).
Adds JOURNEY_ITEM_NOT_FOUND (404) and JOURNEY_ITEM_POSITION_CONFLICT
(409) to ErrorCode for IDOR protection and concurrent-edit feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:27:15 +02:00
Marcel
0b17724785 feat(config): add jackson-databind-nullable for JsonNullable PATCH DTO support
Registers JsonNullableModule globally so JsonNullable<String> in
JourneyItemUpdateDTO can distinguish absent (unchanged) from explicit
null (clear field) on PATCH operations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:26:26 +02:00
Marcel
df5d880e09 fix(review): GeschichtenCard uses GeschichteSummary type; focus-visible on journey links; fix stale tests
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- GeschichtenCard.svelte: use GeschichteSummary instead of Geschichte
  (list endpoint returns summaries; no items/createdAt/updatedAt needed)
- GeschichtenCard.svelte.test.ts: factory returns GeschichteSummary with
  lean author shape; drop Geschichte-only fields (createdAt, groups, etc.)
- geschichten/[id]/+page.svelte: add focus:outline-none focus-visible:ring-2
  focus-visible:ring-focus-ring to journey item document links (WCAG 2.4.7)
- page.svelte.test.ts ([id]): replace stale documents[] factory field with
  items[]; test now checks placeholder text + note caption
- page.svelte.test.ts (new): remove removed initialDocuments from baseData;
  rename test to reflect that only initialPersons is passed through

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:01:50 +02:00
Marcel
45500cc5e2 fix(review): separate note from link label in journey item stub
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
item.note is editorial prose — it must not be used as the anchor label.
Always show the i18n placeholder as the link text; render note as a
caption below the link when present.

Adds TODO(#786) comment so the stub degradation is tracked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:38:44 +02:00
Marcel
b1819867be fix(review): address second-pass PR #787 review blockers
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
- CLAUDE.md: add Geschichte and JourneyItem rows to the Domain Model table
- GeschichteSummary: add @Schema(requiredMode=REQUIRED) to getId, getTitle,
  getStatus, getType, and AuthorSummary.getEmail so the TypeScript generator
  emits non-optional fields when api.ts is next regenerated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:13:38 +02:00
Marcel
2c5f7ac12d fix(review): address PR #787 review blockers — db-orm diagram, C4 diagram, UUID link text
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- db-orm.puml: replace geschichten_documents with journey_items, add type column to geschichten, bump schema version to V72
- l3-backend-3g-supporting.puml: update GeschichteController and GeschichteService descriptions to mention STORY/JOURNEY subtypes and JourneyItem
- geschichten/[id]/+page.svelte: replace raw UUID fallback with m.geschichten_document_link_placeholder() i18n key
- messages/{de,en,es}.json: add geschichten_document_link_placeholder translation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:54:12 +02:00
Marcel
13fa4123c1 docs: update ARCHITECTURE, GLOSSARY, db diagram for JourneyItem and Lesereise
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m49s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / fail2ban Regex (pull_request) Successful in 51s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- ARCHITECTURE.md: expand geschichte domain description — two subtypes
  (STORY/JOURNEY), JourneyItem ownership, ON DELETE SET NULL FK note
- GLOSSARY.md: add JourneyItem and Lesereise terms; update Geschichte
  entry to mention type discriminator
- db-relationships.puml: replace geschichten_documents with journey_items
  (ON DELETE CASCADE to geschichten, ON DELETE SET NULL to documents)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:41:41 +02:00
Marcel
ccdf358b40 test(frontend): fix Geschichte component specs for GeschichteType and JourneyItem model
- GeschichteEditor.svelte.spec.ts: remove docFactory + initialDocuments test;
  rename documentIds test to personIds-only; add familyMember+provisional to
  personFactory (were pre-existing omissions)
- GeschichtenCard.svelte.spec.ts: add type:'STORY', replace documents:[] with
  items:[], change body null→undefined to match Geschichte schema
- GeschichtenCard.svelte.test.ts: add status/type/createdAt/updatedAt to factory;
  cast result as Geschichte to avoid spread-widening type inference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:40:17 +02:00
Marcel
e6c890c61e feat(frontend): update generated API types and Geschichte routes for JourneyItem model
- api.ts: add GeschichteType, JourneyItem, GeschichteSummary schemas;
  remove documentId param from list endpoint; change list response to
  GeschichteSummary[]; add type + items to Geschichte; remove documents field
- GeschichteEditor: remove DocumentMultiSelect + documentIds from payload
  (journey items are managed via the future Lesereisen editor, not here)
- GET /geschichten page: remove documentId filter from server load + URL logic
- geschichten/new: remove documentId pre-population from server load
- geschichten/[id]: replace g.documents with g.items (document-backed JourneyItems)
- geschichten/new + [id]/edit: remove documentIds from submit payload type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:39:53 +02:00
Marcel
93ef26690f test(geschichte): add JourneyItemIntegrationTest, GeschichteListProjectionTest, GeschichteHttpTest; update unit tests
New tests:
- JourneyItemIntegrationTest: @OrderBy, cascade delete, orphan removal,
  type round-trip, note-only item, document-backed item, ON DELETE SET NULL,
  CHECK constraint violation (9 tests)
- GeschichteListProjectionTest (@DataJpaTest): findSummaries status filter,
  AuthorSummary, type field, authorId filter, AND-semantics person filter (8 tests)
- GeschichteHttpTest (@SpringBootTest RANDOM_PORT): list 200, getById 200 with
  JOURNEY items, getById 404, DRAFT hidden from non-BLOG_WRITE user (5 tests)

Updated tests:
- GeschichteServiceTest: mock signature (3 args), anonymous GeschichteSummary
  stubs (Jackson can't serialize Mockito interface mocks), remove documentId cases
- GeschichteControllerTest: summaryStub() concrete anonymous impl,
  updated mock matcher to 3-arg list()
- GeschichteServiceIntegrationTest: List<GeschichteSummary> return type,
  3-arg list() calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:25:18 +02:00
Marcel
439385dd35 refactor(geschichte): update service, controller, repository for JourneyItem model
- GeschichteService.list() now returns List<GeschichteSummary> via JPQL
  projection query; accepts (status, personIds, limit); DRAFT clamp for
  non-BLOG_WRITE users; AND-semantics person filter with sentinel UUID guard
- GeschichteService.getById() is @Transactional(readOnly=true) and calls
  Hibernate.initialize(g.getItems()) to force-init the LAZY bag under
  open-in-view=false
- GeschichteRepository: add findSummaries() JPQL query with person subquery
- GeschichteController.list(): remove documentId param, change return type
  to List<GeschichteSummary>
- GeschichteSpecifications: remove hasDocument() and documentSubquery() —
  TODO left for lesereisen-editor follow-on

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:24:50 +02:00
Marcel
b3ce9b930f feat(geschichte): add GeschichteType, JourneyItem entity, GeschichteSummary, and V72 migration
- GeschichteType enum {STORY, JOURNEY} — default STORY
- JourneyItem entity replaces geschichten_documents junction table;
  position-ordered, document_id nullable (note-only items allowed),
  CHECK(document_id IS NOT NULL OR note IS NOT NULL)
- GeschichteSummary interface projection for list() queries (avoids lazy-init)
- Geschichte entity gains `type` + `items` (LAZY, orphanRemoval, CascadeType.ALL)
  replacing the old `documents` ManyToMany bag
- GeschichteUpdateDTO: remove documentIds (replaced by JourneyItem API)
- V72 migration: adds `type` column, creates `journey_items` table with
  FK ON DELETE CASCADE (geschichte) / ON DELETE SET NULL (document),
  migrates geschichten_documents ordered by meta_date, drops junction table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:24:24 +02:00
d650b6c066 refactor(search): remove NLP/smart-search feature entirely (#772)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
## Summary

- Removes the NLP/smart-search feature completely — the feature was too unreliable and slow; users get better results with the regular search filters
- Deletes the entire backend `search/` package (NlSearchController, NlQueryParserService, NlpClient, NlSearchRateLimiter — 14 classes + 6 test classes)
- Deletes the `nlp-service/` Python microservice (FastAPI, rapidfuzz, DB-backed person matching)
- Removes all frontend NL search components: SmartModeToggle, SmartSearchStatus, InterpretationChipRow, DisambiguationPicker, chip-types, theme-chip-removal
- Strips smart-mode logic from SearchFilterBar and documents/+page.svelte
- Removes `SMART_SEARCH_UNAVAILABLE` / `SMART_SEARCH_RATE_LIMITED` error codes from backend, frontend types, and all three i18n files (de/en/es)
- Removes `nlp-service` container and `APP_NLP_BASE_URL` from both docker-compose files
- Removes Ollama/NLP Prometheus scrape job and Grafana dashboard
- Deletes ADRs 028 (×2), 034, 035

## Test plan

- [ ] Backend compiles: `cd backend && ./mvnw compile -q` → BUILD SUCCESS
- [ ] Frontend server tests pass: `cd frontend && npm run test -- --project=server`
- [ ] No NLP/smart-search references remain in source: `grep -r "SmartSearch\|NlSearch\|nlp-service\|SMART_SEARCH" backend/src frontend/src`
- [ ] `docker compose config` validates both compose files
- [ ] Search page loads, filter bar works, no smart-mode toggle visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #772
2026-06-08 10:57:00 +02:00
Marcel
8e63867ad8 docs(specs): UI specs for Lesereisen reader and Journey editor
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 4m2s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
nightly / deploy-staging (push) Successful in 2m44s
lesereisen-reader-spec.html — Issue #752
  LR-0 type selector on /geschichten/new
  LR-1 REISE badge on the list
  LR-2 Journey reader (ordered cards, interlude asides, no position numbers)

lesereisen-editor-spec.html — Issue #753
  LE-1 empty JourneyEditor layout
  LE-2 editor with mixed items (documents + interludes, drag handles)
  LE-3 inline note-editing state
  LE-4 mobile layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:07:34 +02:00
Marcel
6b0a06e8b1 feat(nlp-service): scaffold — models, requirements, CLAUDE.md
Task 1: Create standalone FastAPI service scaffold with models, test framework,
and documentation. Includes ParseRequest, ParseResponse Pydantic models matching
OllamaExtraction contract, plus three passing tests validating model validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:11:34 +02:00
Marcel
7c1eef710c docs(nlp): add spaCy NLP service implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:52:07 +02:00
Marcel
03e22a2f26 docs(nlp): add spaCy NLP service prototype design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:40:00 +02:00
Marcel
6878419156 merge: resolve conflicts with origin/main (#763 person name-match integration)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Successful in 3m20s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m48s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
- Drop unused MAX_CANDIDATES constant (not referenced in service)
- Keep detached-entity safety comment in resolveTags()
- Add 3 new partial-name match tests (23a/b/c) from #763
- Use resolveByName() API in test 28 (replaces findByDisplayNameContaining)
- Add NameMatches glossary entry from #763

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:50:48 +02:00
Marcel
09b77e9b36 test(person): pin fetchPool dedup when one person matches two tokens (#763 review)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m20s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m53s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
Assert that when the same person id is returned by two different token
fetches, the person appears exactly once in the result -- pinning
fetchPool's putIfAbsent dedup so a future refactor can't silently
double-classify a candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9d202b042b test(person): close fetch-to-classify seam for alias matches on real Postgres (#763 review)
AC#4 (maiden alias -> direct) and AC#5 (alias first name -> fetchable +
classifiable) were each split across PersonRepositoryTest (the fetch) and
PersonServiceTest (the classifier with stubs) -- nothing walked
searchByName -> resolveByName end-to-end on real Postgres. Add two tests
in the existing @DataJpaTest slice that build a real PersonService over
the autowired repositories, persist a person with a MAIDEN_NAME alias and
one with an alias firstName, and assert both classify as direct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
8429b1e9f8 fix(search): derive disambiguation trigger aria-label from match count (#763 review)
The trigger hardcoded the multiple-people label for every count, so a
single did-you-mean picker announced "Mehrere Personen gefunden" to
screen readers while sighted users saw one name and a "Meintest du …?"
heading. Derive the trigger's accessible name from persons.length: a
single suggestion reuses the heading prop, two or more keep the
multiple-people label. Visible truncated name span unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
6959651b36 docs(search): document NameMatches and resolveByName (#763)
GLOSSARY entry for NameMatches (direct vs partial name-match strength and how
the search layer maps it); person/README adds resolveByName to the public
surface. No ADR — the matching rule is localized and justified inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
0ef4f4f07c feat(search): case-appropriate disambiguation picker copy (#763)
A 1-item picker now reads "Meintest du …?" (a single direct match auto-selects
and never reaches the picker), while ≥2 keeps the "Person auswählen" framing.
The prompt lives in a visible, non-truncated panel heading (the trigger span
clips at 320px), and the "(auswählen…)" cue is dropped for the 1-item case.
DisambiguationPicker takes heading + showCue props; the page derives both from
ambiguousPersons.length. New search_disambiguation_did_you_mean key in de/en/es.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
f1bb9d3a69 feat(search): map direct/partial NameMatches into resolve buckets (#763)
resolveNames now delegates to PersonService.resolveByName and maps by match
strength: 1 direct → resolved (auto-select), ≥2 direct → ambiguous, 0 direct
with partials → ambiguous suggestions, 0 candidates → folded into full-text.
A single direct match no longer forces the picker when looser substring hits
coexist. The MAX_CANDIDATES cap moved into PersonService (after classification);
the MAX_NAME_LENGTH guard, resolved-cap overflow, and sender/receiver mapping
are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
ca52145556 feat(person): add resolveByName for direct/partial name matching (#763)
Token-set containment over all of a person's name components (firstName,
lastName, alias, each PersonNameAlias first+last, title) decides direct vs
partial. Orchestrates tokenize → cap(8) → fetch pool → classify → cap(10)
after classification, with an empty-token guard and a PII-free debug log of
the outcome bucket. MAX_TOKENS is a DoS control; the after-classify cap keeps a
direct match that sorts past position 10 among partials. Read-only transaction
keeps lazy nameAliases reachable during classification (ADR-022).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9a26bf75b0 feat(person): match alias first names in searchByName (#763)
The direct-match classifier accepts alias firstName tokens, so the fetch must
surface candidates matchable only via an alias first name. Add a.firstName to
the searchByName LIKE clause (reuses the bound :query — injection-proof). The
person_name_aliases.first_name column already exists; no migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9c616f9fb8 feat(person): add name-match tokenizer for direct matching (#763)
Lowercase, split on whitespace/hyphen/apostrophe, drop empties. Applied
symmetrically to query and candidate name components so "Anna-Maria" and
"Anna Maria" tokenize alike. Foundation for resolveByName direct matching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
0fe0ae5235 docs(search): ADR-028 fix + glossary + C4 diagram for tag resolution (#743)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
2c909f49a8 feat(search): wire theme chip removal to URL navigation in +page.svelte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
87fd0f39bb feat(search): render removable theme chips in InterpretationChipRow
When tagsApplied is true, each resolvedTag renders as a 'Thema: Name'
chip with optional inline color style from the tag's resolved color.
Clicking × calls onRemoveChip('theme', tag.name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
7f3ad8ce89 feat(api): add TagHint schema and extend NlQueryInterpretation with resolvedTags/tagsApplied
Manual update since Docker compose backend runs old build; regenerate with
npm run generate:api once new backend is deployed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
aa1f6436cc feat(i18n): add search_chip_theme_prefix to de/en/es message bundles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
b825076733 test(search): DataJpaTest for descendant-expansion via TagRepository
Verifies the recursive CTE in findDescendantIdsByName expands a parent tag
to include all child IDs, and that findByNameContainingIgnoreCase matches
both parent and child names when the fragment appears in both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
01df815bad test(search): add 11 tag-resolution test cases to NlQueryParserServiceTest
Covers multi-tag match, no-match FTS fallback, mixed resolution, personRole
bypass, cap at 10, short-keyword skip, dedup, rawQuery suppression when all
keywords resolve, flag independence, colour propagation via resolveEffectiveColors,
and colour=null when depth constraint prevents resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
dcd0e725a7 feat(search): implement keyword→tag resolution in NlQueryParserService
Keywords that substring-match the tag taxonomy become OR-union tag filters;
non-matching keywords stay as FTS text. Resolved tags surface in the
NlQueryInterpretation as TagHint objects with effective colours. The
rawQuery fallback is now guarded by hadStructuredMatch to prevent
double-apply when all keywords resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
39ff63921d refactor(search): extract ChipType to chip-types.ts; audit NL fixtures
Pre-implementation step for #743: ChipType union extracted from
InterpretationChipRow and +page.svelte into shared chip-types.ts;
resolvedTags/tagsApplied neutral defaults added to test fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
5a09cd4cb4 feat(search): extend NlQueryInterpretation with resolvedTags + tagsApplied
Positional record fields added; all 3 construction sites updated with neutral
defaults; NlQueryParserService wired for TagService (4th constructor arg);
NlQueryParserServiceTest and NlSearchControllerTest synced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
4e0ebc72c8 feat(search): add TagHint record for NL tag resolution API surface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
0f0d89702d feat(search): add TagService.findByNameContaining for NL tag resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
fb41affd4c docs(search): note vitest-browser workaround for + in path
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Addresses @Sara review: browser tests in this spec fail silently when
the project path contains '+' (common in git worktrees). The comment
tells developers to copy the frontend directory to a clean path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:58:36 +02:00
Marcel
dc366ed403 docs(search): add detached-entity safety comment in resolveTags
Addresses @Markus review: tags fetched by findByNameContaining live outside
any transaction; Hibernate's dirty-check never fires on them. The comment
removes the ambiguity for cold readers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:58:03 +02:00
Marcel
64b7b2315d docs(search): ADR-028 fix + glossary + C4 diagram for tag resolution (#743)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m1s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:42:23 +02:00
Marcel
2a7e133717 feat(search): wire theme chip removal to URL navigation in +page.svelte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:40:33 +02:00
Marcel
5387bc9247 feat(search): render removable theme chips in InterpretationChipRow
When tagsApplied is true, each resolvedTag renders as a 'Thema: Name'
chip with optional inline color style from the tag's resolved color.
Clicking × calls onRemoveChip('theme', tag.name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:33:53 +02:00
Marcel
847874abb3 feat(api): add TagHint schema and extend NlQueryInterpretation with resolvedTags/tagsApplied
Manual update since Docker compose backend runs old build; regenerate with
npm run generate:api once new backend is deployed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:01:11 +02:00
Marcel
573bca4986 feat(i18n): add search_chip_theme_prefix to de/en/es message bundles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:59:58 +02:00
Marcel
86690fdbb6 test(search): DataJpaTest for descendant-expansion via TagRepository
Verifies the recursive CTE in findDescendantIdsByName expands a parent tag
to include all child IDs, and that findByNameContainingIgnoreCase matches
both parent and child names when the fragment appears in both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:59:07 +02:00
Marcel
6cb1025881 test(search): add 11 tag-resolution test cases to NlQueryParserServiceTest
Covers multi-tag match, no-match FTS fallback, mixed resolution, personRole
bypass, cap at 10, short-keyword skip, dedup, rawQuery suppression when all
keywords resolve, flag independence, colour propagation via resolveEffectiveColors,
and colour=null when depth constraint prevents resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:57:17 +02:00
Marcel
fc557bd9ae feat(search): implement keyword→tag resolution in NlQueryParserService
Keywords that substring-match the tag taxonomy become OR-union tag filters;
non-matching keywords stay as FTS text. Resolved tags surface in the
NlQueryInterpretation as TagHint objects with effective colours. The
rawQuery fallback is now guarded by hadStructuredMatch to prevent
double-apply when all keywords resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:54:33 +02:00
Marcel
e94414b81a refactor(search): extract ChipType to chip-types.ts; audit NL fixtures
Pre-implementation step for #743: ChipType union extracted from
InterpretationChipRow and +page.svelte into shared chip-types.ts;
resolvedTags/tagsApplied neutral defaults added to test fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:49:54 +02:00
Marcel
7eee688ce9 feat(search): extend NlQueryInterpretation with resolvedTags + tagsApplied
Positional record fields added; all 3 construction sites updated with neutral
defaults; NlQueryParserService wired for TagService (4th constructor arg);
NlQueryParserServiceTest and NlSearchControllerTest synced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:37:45 +02:00
Marcel
8905135006 feat(search): add TagHint record for NL tag resolution API surface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:35:24 +02:00
Marcel
8bd8390891 feat(search): add TagService.findByNameContaining for NL tag resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:34:34 +02:00
Marcel
ed98729f75 docs(adr): record prod Ollama deployment + keep-alive decision (ADR-034)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m23s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m52s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
nightly / deploy-staging (push) Successful in 2m44s
Capture the why behind deploying Ollama to prod/staging compose: the
corrected init recipe (supersedes ADR-028 §10's never-functional curl
loop), the OLLAMA_KEEP_ALIVE=-1 pin (so a future maintainer doesn't
optimize it away and reintroduce the post-idle cold-load 503), the
30->60s timeout NFR, and the memswap==mem hard-OOM trade-off.

Addresses #759 review (Markus #3, Nora #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:16:03 +02:00
Marcel
db87a64cc0 docs(c4): de-duplicate Ollama container in l2-containers diagram
The diagram declared Container(ollama, ...) twice — an alias collision that
renders a duplicate box. It also declared the backend->ollama relationship
twice. Keep the richer 'Ollama LLM Service' declaration and the more
specific 'NL query parsing (POST /api/generate)' relationship; drop the
duplicates.

Addresses #759 review (Markus #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:14:26 +02:00
Marcel
d7d6d0638c fix(infra): make dev Ollama model-init offline-safe
Mirror the prod hardening in the dev stack: guard the model pull with
`ollama list | grep -q <model>` so an already-cached model exits clean
without a registry round-trip. Keeps dev and prod on one recipe.

Addresses #759 review (Tobias #1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:13:19 +02:00
Marcel
a2f37f85a6 fix(infra): make prod Ollama model-init offline-safe
The init command unconditionally ran `ollama pull`, which contacts the
registry to verify the manifest digest even when the model is already on
the volume. A host reboot during a registry/upstream-network blip would
then fail init non-zero, the `service_completed_successfully` gate would
never be met, and the ollama service (hence NL search) would stay down
until the registry was reachable again.

Guard the pull with `ollama list | grep -q <model>` so a cached model
exits clean without any registry round-trip.

Addresses #759 review (Tobias #1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:12:21 +02:00
Marcel
f22a1a1cfa docs(deploy): fix prod Ollama volume name to match hyphenated compose volume
docker-compose.prod.yml declares the volume as `ollama-models` (hyphen),
so the compose-project-prefixed name is `archiv-production_ollama-models`,
not the underscored `archiv-production_ollama_models` the model-upgrade
guide documented. The documented `docker volume rm` would not have matched
the real volume.

Addresses #759 review (Tobias #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:09:48 +02:00
Marcel
2a0863cf3e docs(deploy): correct Ollama read timeout default to 60s
application.yaml sets app.ollama.timeout-seconds: 60 (raised from 30 to
absorb the cold model load on the first query after an Ollama restart),
but DEPLOYMENT.md still documented 30. A doc that contradicts the shipped
value is a traceability defect.

Addresses #759 review (Markus, Felix, Elicit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:08:55 +02:00
Marcel
9e97687d0f fix(search): pin Ollama model in memory + raise read timeout
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m55s
CI / fail2ban Regex (pull_request) Successful in 51s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
NL search recovered after deploy but went 503 again after a few minutes:
Ollama unloads the model after its default ~5 min keep-alive, so the next
query cold-loads the 4.7 GB model and exceeds the backend's 30s read
timeout (ResourceAccessException -> SMART_SEARCH_UNAVAILABLE). Warm
inference is ~18s; the cold load after idle is what timed out.

- docker-compose.{prod,yml}: set OLLAMA_KEEP_ALIVE=-1 on the ollama
  service so the model stays resident and never pays a cold-load penalty
  during normal operation (verified on staging: `ollama ps` -> UNTIL
  "Forever"; host has 47 GB free).
- application.yaml: raise app.ollama.timeout-seconds 30 -> 60 so the one
  unavoidable cold load (first query after an Ollama restart, before the
  model is pinned) completes instead of timing out.

Refs #758

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:27:02 +02:00
Marcel
b665e1132d fix(infra): deploy Ollama to prod/staging compose + fix broken model-init recipe
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m0s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
NL search returned 503 (SMART_SEARCH_UNAVAILABLE / "Intelligente Suche
nicht verfügbar") on staging because Ollama was never reachable.

Two defects, both downstream of #737:

1. Ollama was added only to the dev docker-compose.yml. Staging/prod
   deploy from the self-contained docker-compose.prod.yml, which had no
   ollama service — so the backend (defaulting to http://ollama:11434)
   hit a non-existent host (ResourceAccessException -> 503).

2. The merged model-init recipe never worked: the ollama/ollama image
   ENTRYPOINT is `ollama` (so `command: sh -c ...` ran as `ollama sh ...`
   -> "unknown command sh"), and the image ships no curl (so both the
   readiness loop and the healthcheck could never pass).

- docker-compose.prod.yml: add ollama-model-init + ollama services and
  the ollama-models volume, with the corrected recipe (entrypoint
  override to /bin/sh -c, `ollama list` for readiness and healthcheck).
- docker-compose.yml: fix the same broken entrypoint/command and the
  curl healthcheck so the dev stack actually starts Ollama.

Verified on staging end-to-end: model-init exits 0, ollama healthy,
backend reaches /api/tags, inference succeeds within the 8g limit.

Refs #758

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:20:22 +02:00
Marcel
87af9ab446 docs(c4): add smart-search components to l3-frontend diagram (#739 review)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 3m51s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
Markus (architect): document SearchFilterBar + the search/ components
(SmartModeToggle, InterpretationChipRow, SmartSearchStatus,
DisambiguationPicker) and the POST /api/search/nl relation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:27:00 +02:00
Marcel
0058b297d8 fix(search): enlarge sub-12px text for senior legibility (#739 review)
Leonie (UX): the toggle pill (text-[7.5px]) and loading subtitle
(text-[9px]) were below the 12px floor for the 60+ audience. Bump both
to text-xs and the toggle icon to h-3.5/w-3.5. Overrides the visual
spec's tokens, which conflicted with the issue's own legibility mandate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:26:24 +02:00
Marcel
230f23e37c test(search): add NL search happy-path Playwright E2E (#739)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Mock POST /api/search/nl (delayed fixture: 2-name directional + applied
keyword), assert loading announcement → chips render → axe-clean in light
and dark → removing the keyword chip re-runs a keyword GET with the
remaining sender+receiver params. Adds a data-testid wrapper on the NL
results region for axe scoping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:58:15 +02:00
Marcel
e604967a3f docs(search): document src/routes/search/ component directory (#739)
Add the smart-search sub-component directory to the frontend Project
Structure tree (merge blocker per #739).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:57:59 +02:00
Marcel
169e1ad9de test(search): cover smart-mode chip lifecycle hooks (#739)
SearchFilterBar drives chip-clearing via onModeToggle (mode switch) and
onSmartSearch (new query); pin that callback contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:54:25 +02:00
Marcel
f2f42ed415 feat(search): orchestrate NL search on the documents page (#739)
Lift smartMode to documents/+page.svelte and drive the full smart-search
lifecycle: POST /api/search/nl via csrfFetch, loading/error panels, chip
row, single-select disambiguation, and a transparent empty state. Chip
removal and disambiguation selection map the interpretation to keyword
params and re-run via GET (Option A in-page fallback). Mode toggle and
new queries reset prior interpretation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:54:07 +02:00
Marcel
5945824b54 feat(search): wire SmartModeToggle into SearchFilterBar (#739)
Add smartMode $bindable plus onSmartSearch/onModeToggle callbacks. The
toggle pill sits in the input's right slot (decorative icon moved to the
left); smart mode disables the live oninput keyword search, adds
maxlength=500, and submits the NL query on Enter. 4 integration specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:47:05 +02:00
Marcel
fa41394e66 feat(search): add DisambiguationPicker single-select disclosure (#739)
Accessible disclosure: aria-expanded/aria-controls trigger, focus moves
into the option list on open, Escape and click-outside close and return
focus to the trigger, selecting a candidate emits onSelect. Single-select
(GET re-run) per the resolved #738 open decision — backend has no
multi-sender OR param. 5 vitest-browser-svelte specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:43:27 +02:00
Marcel
fb00c7818e feat(search): add SmartSearchStatus full-area panels (#739)
Loading panel (role=status, motion-safe spinner + pulsing subtitle) and
combined error panels: 503 (red icon + switch-to-keyword button) and
429 (amber clock icon, no action button). 5 vitest-browser-svelte specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:40:28 +02:00
Marcel
8ed65f8602 feat(search): add InterpretationChipRow component (#739)
Renders type-prefixed chips (Absender/Zeitraum/Stichwort), a single
directional chip for 2-name queries, gates keyword chips on
keywordsApplied, and emits onRemoveChip(type, value?). Truncating name
spans keep the 44px × button visible; chip wrappers show a focus ring.
9 vitest-browser-svelte specs (red/green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:38:51 +02:00
Marcel
9e425c98a1 feat(search): add SmartModeToggle pill component (#739)
Toggle pill with aria-pressed, active/resting styles matching the
AND/OR operator button pattern, and mobile-expanded KI/Text labels.
4 vitest-browser-svelte specs (red/green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:35:05 +02:00
Marcel
ddce268113 feat(search): add NL search frontend i18n keys (de/en/es)
Toggle labels, loading panel, error panels (503/429), empty-state
retry, chip type-prefixes + remove label, and disambiguation strings
for the smart search UI (#739). Formal Sie form per project standard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:32:50 +02:00
4a43962c98 Merge pull request 'feat(search): NL search backend — POST /api/search/nl with Ollama integration (#738)' (#756) from worktree-feat+issue-738-nl-search-backend into main
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m17s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m43s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
Reviewed-on: #756
2026-06-06 16:52:43 +02:00
Marcel
9a9e1c4c40 merge(search): resolve DEPLOYMENT.md conflict — keep setup + upgrade sections
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
Both the first-time model pull runbook (from this branch) and the model
upgrade procedure (from main) belong in DEPLOYMENT.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:47:49 +02:00
Marcel
62c8ce4cb2 docs(search): add NL search visual spec — toggle pill, chips, full-area states (#739)
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
Covers the SmartModeToggle pill (inside the search input, Google AI Mode
style), InterpretationChipRow anatomy, DisambiguationPicker, and all
status/error/empty states as full-result-area panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:47:09 +02:00
Marcel
4c620619d4 fix(search): formal Sie form in German error strings; clean up DocumentService imports
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m57s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
- error_smart_search_unavailable/rate_limited now use "Sie" (formal) to
  match the tone of all existing German error messages
- Replace inline FQNs in DocumentService.buildPersonSpec with proper
  JoinType + Predicate imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:46:40 +02:00
Marcel
44baff9c9c docs(search): update CLAUDE.md, GLOSSARY, DEPLOYMENT, and C4 diagrams
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:16:04 +02:00
Marcel
4634da9865 feat(search): add @Schema annotations and regenerate TypeScript API types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:11:01 +02:00
Marcel
79e4a3f9db feat(search): add searchDocumentsByPersonId with Specification-based sender/receiver query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:04:54 +02:00
Marcel
70e8a6e6ad feat(search): implement NlSearchController with @WebMvcTest tests (7 cases)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:58:35 +02:00
Marcel
3af1095d13 feat(search): implement NlQueryParserService with Mockito tests (23 cases)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:54:45 +02:00
Marcel
8c835e957a feat(search): implement RestClientOllamaClient with WireMock tests
Switch to wiremock-jetty12 artifact and force ee10 Jetty deps to 12.1.8
to resolve compatibility with Spring Boot 4's Jetty 12.1.8 core.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:43:49 +02:00
Marcel
fe8fcba7a7 feat(search): add NlSearchRateLimiter with Bucket4j/Caffeine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:39:06 +02:00
Marcel
e0c80ac193 feat(search): add Ollama and rate-limit config properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:37:24 +02:00
Marcel
005265b5a8 feat(search): add NL search error codes and i18n strings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:36:13 +02:00
Marcel
684c6e63de feat(search): add NL search domain records and OllamaClient interfaces
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:33:56 +02:00
Marcel
e27d52b9ee docs(c4): add L3 backend search component diagram
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:32:40 +02:00
Marcel
6f5497c7bf docs(adr): ADR-028 — NL search via Ollama
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:31:53 +02:00
Marcel
e0fac783e8 feat(person): add findByDisplayNameContaining service method
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:30:30 +02:00
Marcel
202ea85a58 build(deps): add org.wiremock:wiremock 3.9.2 as test dependency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:28:55 +02:00
Marcel
7679596c70 docs(ollama): add model upgrade runbook + post-deploy smoke test to DEPLOYMENT.md
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 3m16s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
Addresses Elicit's and Sara's review concerns on PR #749:
- Expand §6 ollama_models section into a full model upgrade runbook (step-by-step
  docker volume rm + recreate, including production volume name prefix)
- Add re-deploy idempotency note to §3.4 (init container exits quickly when model
  already present on the volume)
- Add NL search smoke test to §3.4 (curl command distinguishing 200 from 503
  NL_SEARCH_UNAVAILABLE)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
3d5dcd1f18 docs(deployment): fix OLLAMA_API_KEY version ref and add --wait warning
Updated OLLAMA_API_KEY env vars table from 0.6.5 to 0.6.5 or 0.30.6 to
match both tested versions. Added an explicit warning in §3.4 that
docker compose up -d --wait blocks for 60–90 min on first deploy when the
model pull has not yet completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
52fca38f0f docs(env): correct OLLAMA_API_KEY comment — tested on 0.6.5 and 0.30.6
Both versions were tested and neither enforces the key. Comment updated to
say "0.6.5 or 0.30.6" and surface archiv-net as the sole effective control.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
662a8f3e80 fix(infra): interpolate APP_OLLAMA_BASE_URL so .env empty-value disables Ollama
Hardcoded literal overrides any .env setting — setting APP_OLLAMA_BASE_URL=
in .env had no effect on the backend container. Now uses the same pattern
as APP_OCR_TRAINING_TOKEN with a safe default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
cbba95c3f8 docs(c4): fix Ollama container version 0.6.5 → 0.30.6 in l2-containers.puml
Diagram must match the pinned image version in docker-compose.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
3536ed884c docs(adr): fix ADR-028 §12 false API-key claim, stale TBD, and §7 title
§12 stated OLLAMA_API_KEY guards against lateral movement — contradicts
§7's empirical finding that it is not enforced. Replaced with an accurate
note referencing §7. Stale pre-merge placeholder in Consequences ("Three
TBD items must be resolved") removed; all three are resolved. §7 section
title updated from "0.6.5" to "0.6.5 and 0.30.6" to match the body text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
5a939d9222 fix(infra): escape \$\$SERVE_PID in compose command to prevent interpolation (#737)
Docker Compose interpolates $VAR in command strings — use $$ to pass a
literal $ to the shell so SERVE_PID=$! and kill $SERVE_PID work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
93e90424ab docs(adr): update ADR-028 with 0.30.6 verified findings for API key + read_only (#737)
- OLLAMA_API_KEY: non-enforcement confirmed on both 0.6.5 and 0.30.6
- read_only: true: confirmed working on both 0.6.5 and 0.30.6
- Peak RSS during pull: ~108 MiB (well under 2g limit)
- All TBD placeholders resolved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
e8f3004c4f feat(infra): add Ollama env vars to .env.example (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
9637ebbca2 feat(infra): add Ollama Docker Compose services for NL search (#737)
- ollama-model-init: one-shot init container that pulls qwen2.5:7b-instruct-q4_K_M
  into the ollama_models volume on first start
- ollama: main inference service on archiv-net (expose: only, no public port)
- ollama_models named volume for persistent model storage
- APP_OLLAMA_BASE_URL + APP_OLLAMA_API_KEY added to backend env
- Both services: cap_drop ALL, no-new-privileges, read_only+tmpfs (ADR-019 + ADR-028)
- start_period: 60s — model pre-pulled by init container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
df10a42069 docs(deploy): document Ollama hardware requirements, env vars, and ops notes (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:59:35 +02:00
Marcel
64120a30b5 docs(arch): add Ollama container to C4 level-2 container diagram (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:58:49 +02:00
Marcel
25252fc709 feat(observability): add Grafana Ollama inference latency dashboard (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:58:49 +02:00
Marcel
1f379a161d fix(observability): fix OCR target name + add Ollama scrape job (#737)
- prometheus.yml: ocr:8000 → ocr-service:8000 (Docker service name is
  ocr-service, not ocr — current scrape target has never resolved)
- Add Ollama scrape job on ollama:11434 /metrics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:58:49 +02:00
Marcel
c0d034c85d docs(adr): add ADR-028 — Ollama Docker Compose service for NL search (#737)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:58:49 +02:00
Marcel
ca93cde06e docs(infra): correct server specs — Hetzner Serverbörse i7-6700 64 GB, not CX32
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m18s
CI / OCR Service Tests (push) Successful in 21s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
Replace all references to the CX32 VPS (8 GB RAM, Hetzner Cloud) with the
actual production server: a Hetzner Serverbörse dedicated server with an
Intel Core i7-6700 (4C/8T, 3.4 GHz) and 64 GB RAM.

Affected files:
- .claude/personas/devops.md — monthly cost line + upgrade example
- docs/infrastructure/production-compose.md — sizing section + cost table
- docs/DEPLOYMENT.md — OCR memory table + OCR_MEM_LIMIT env var description
- docs/adr/004-pdfbox-thumbnails.md — thumbnailExecutor memory ceiling note
- docs/adr/021-tmpdir-persistent-volume-staging.md — OOMKill rationale in alternatives

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:51:07 +02:00
Marcel
7629e35897 docs(adr): renumber tag case-collision ADR 032 → 033 to resolve number clash (#731)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m13s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m40s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
Both #730 (tag case-collision) and #684 (person-delete DB integrity) landed
an ADR-032 on main. Renumber the tag/case-collision one to 033 — it is
referenced only from this PR's person-domain comments and its own file, so the
move is self-contained and touches no Flyway migration. The person-delete
ADR-032 and the V71 migration comment that cites it are deliberately left
untouched (editing an applied migration would drift its Flyway checksum).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:52:25 +02:00
Marcel
cd741b9f57 docs(person): clarify case-collision scope at the exact-case lookups (#731)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Review noted the "never throws" claim was overstated: the exact-case Optional
lookups still surface a NonUniqueResultException on two byte-identical
same-case rows. That is a true data anomaly out of #731's scope (ambiguous =
case-insensitive) and resolves to the opaque INTERNAL_ERROR, never a wrong
row. Record that boundary at both resolution points and in ADR-032 so the gap
is not silently assumed covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:36:22 +02:00
Marcel
ddf378aaac fix(person): resolve ambiguous sender names to null on upload (#731)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m38s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
findByName resolved via Optional<Person>
findByFirstNameIgnoreCaseAndLastNameIgnoreCase, which threw
NonUniqueResultException once two people shared a first+last name case-
insensitively (hans müller / Hans Müller) — a 500 on the routine upload path
(DocumentService.storeDocument sender resolution).

findByName now resolves exact-case → single case-insensitive match → else
empty. The sender path deliberately diverges from the alias path: an
ambiguous name leaves the sender UNSET rather than guessing the lowest id,
because correct provenance beats a confidently-wrong pre-fill a reviewer
won't re-check. The two new name queries use explicit HQL equality so a null
first name binds as `= NULL` (no match) instead of the derived-query fold to
`first_name IS NULL`, which would widen a last-name-only row in as a sender.

Pins the opaque error path (IncorrectResultSizeDataAccessException stays
INTERNAL_ERROR with no Hibernate/SQL/row-count leak) and extends ADR-032 with
the Person section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:03:04 +02:00
Marcel
20cfe41f21 fix(person): resolve case-colliding aliases without throwing (#731)
findOrCreateByAlias resolved via Optional<Person> findByAliasIgnoreCase,
which throws NonUniqueResultException once two aliases collide only by case
(müller / Müller) — a generic 500 on the importer path. Mirror the #730 tag
fix: resolve exact-case first, then the lowest-id case-insensitive sibling,
then create-when-absent (institution/group and maiden-name alias preserved).
The throwing Optional<…>IgnoreCase variant is deleted so it can't be reused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:50:21 +02:00
152 changed files with 13658 additions and 883 deletions

View File

@@ -154,9 +154,9 @@ Schedule monthly automated restore tests. If the restore fails, the backup is wo
```
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
3. **Upgrading VPS tier before profiling**
3. **Upgrading hardware before profiling**
```
# "The app feels slow" → upgrade from CX32 to CX42
# "The app feels slow" → order more RAM / a faster CPU
# Actual cause: unindexed query scanning 100k rows
```
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
@@ -404,8 +404,8 @@ Hetzner Object Storage (S3-compatible, replaces MinIO in prod)
Prometheus + Loki + Alertmanager
```
### Monthly Cost: ~23 EUR
CX32 VPS (4 vCPU, 8GB RAM): 17 EUR · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
### Monthly Cost: ~6 EUR (excl. server)
Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM): see invoice · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
### Reference Documentation
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`

View File

@@ -72,6 +72,25 @@ VITE_SENTRY_DSN=
# Sentry/GlitchTip auth token for source map upload at build time (optional)
SENTRY_AUTH_TOKEN=
# NL search — Ollama LLM inference
# Leave APP_OLLAMA_BASE_URL empty to disable NL search (safe default for CX32 / CI).
# Set to http://ollama:11434 to enable. Requires CX42 (16 GB RAM) to run alongside OCR.
APP_OLLAMA_BASE_URL=http://ollama:11434
# CPU limit: 4.0 is safe on both CX32 (4 vCPUs) and CX42 (8 vCPUs).
# Raise to 7.5 on CX42 for full throughput.
OLLAMA_CPU_LIMIT=4.0
# Memory limit: requires CX42 (16 GB) to run alongside OCR.
# Reduce or set APP_OLLAMA_BASE_URL= on smaller hosts.
OLLAMA_MEM_LIMIT=8g
# Ollama API key — set on the Ollama service to restrict inference API access on archiv-net.
# Generate with: openssl rand -hex 32
# NOTE: Empirically verified that OLLAMA_API_KEY is NOT enforced in Ollama 0.6.5 or 0.30.6 (ADR-028 §7).
# archiv-net network isolation is the only effective access control. Retained for forward compatibility.
OLLAMA_API_KEY=
# Production SMTP — uncomment and fill in to send real emails instead of catching them
# APP_BASE_URL=https://your-domain.example.com
# MAIL_HOST=smtp.example.com

View File

@@ -86,7 +86,8 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
├── filestorage/ FileService (S3/MinIO)
├── geschichte/ Geschichte (story) domain
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
├── notification/ Notification domain + SseEmitterRegistry
├── ocr/ OCR domain — OcrService, OcrBatchService, training
@@ -105,13 +106,15 @@ backend/src/main/java/org/raddatz/familienarchiv/
### Domain Model
| Entity | Table | Key relationships |
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| Entity | Table | Key relationships |
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -152,7 +155,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
### DTOs
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs)**except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
@@ -160,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints).
### Security / Permissions
@@ -268,7 +271,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints).
---

View File

@@ -33,7 +33,8 @@ src/main/java/org/raddatz/familienarchiv/
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
├── filestorage/ # FileService (S3/MinIO)
├── geschichte/ # Geschichte (story) domain
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
├── notification/ # Notification domain + SseEmitterRegistry
├── ocr/ # OCR domain — OcrService, OcrBatchService, training

View File

@@ -41,6 +41,27 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Force WireMock's ee10 Jetty transitive deps to match Spring Boot's 12.1.8 core -->
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlet</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlets</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-webapp</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-ee</artifactId>
<version>12.1.8</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
@@ -137,6 +158,12 @@
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-jetty12</artifactId>
<version>3.9.2</version>
<scope>test</scope>
</dependency>
<!-- Excel Bearbeitung (Apache POI) -->
<dependency>

View File

@@ -50,10 +50,25 @@ public enum AuditKind {
ADMIN_FORCE_LOGOUT,
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
LOGIN_RATE_LIMITED;
LOGIN_RATE_LIMITED,
// --- Reading Journeys (Lesereisen) ---
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
JOURNEY_ITEM_ADDED,
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
JOURNEY_ITEM_REMOVED,
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
JOURNEY_ITEM_NOTE_UPDATED,
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
JOURNEY_ITEMS_REORDERED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
JOURNEY_ITEMS_REORDERED
);
}

View File

@@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@EntityGraph("Document.list")
Page<Document> findAll(Pageable pageable);
// Loader for the relevance fast path: list-item enrichment reads tags after the
// repository call returns, so the fetch shape must match the spec-based findAll
// overloads above. Plain findAllById carries no entity graph and must not feed
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
@EntityGraph("Document.list")
List<Document> findByIdIn(Collection<UUID> ids);
// Findet ein Dokument anhand des ursprünglichen Dateinamens
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
Optional<Document> findByOriginalFilename(String originalFilename);
@@ -57,6 +64,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@EntityGraph("Document.full")
List<Document> findByReceiversId(UUID receiverId);
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
List<Document> findByTags_Id(UUID tagId);

View File

@@ -32,6 +32,8 @@ 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 jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -849,14 +851,14 @@ public class DocumentService {
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
// Preserve ts_rank order from SQL across the JPA findAllById call.
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
Map<UUID, Integer> rankMap = new HashMap<>();
List<UUID> pageIds = new ArrayList<>();
for (int i = 0; i < ftsPage.hits().size(); i++) {
rankMap.put(ftsPage.hits().get(i).id(), i);
pageIds.add(ftsPage.hits().get(i).id());
}
List<Document> docs = documentRepository.findAllById(pageIds).stream()
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
.toList();
return buildResultPaged(docs, text, pageable, ftsPage.total());
@@ -1004,6 +1006,28 @@ public class DocumentService {
return doc;
}
/**
* Lightweight summary lookup for internal use (e.g. journey item append validation).
*
* <p><strong>Security contract — read before calling:</strong>
* <ol>
* <li>This method intentionally bypasses per-document scope checks and
* tag-colour resolution. It must only be invoked after
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
* the controller layer, guaranteeing the caller is an authenticated
* author.</li>
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
* JOURNEY-type check that fires before this call — so the method is never
* reached for STORY-type Geschichten.</li>
* </ol>
* Under the current single-tenant model every authenticated author shares the
* same document scope, so skipping per-document scope checks is safe.
*/
public Document findSummaryByIdInternal(UUID id) {
return documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}
/**
* Loads a document for the detail view, additionally flagging whether it has any
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
@@ -1033,6 +1057,28 @@ public class DocumentService {
return documentRepository.findByReceiversId(receiverId);
}
public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
Person person = personService.getById(personId);
Specification<Document> spec = buildPersonSpec(person, from, to);
Page<Document> page = documentRepository.findAll(spec, pageable);
List<DocumentListItem> items = enrichItems(page.getContent(), null);
return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
}
private Specification<Document> buildPersonSpec(Person person, LocalDate from, LocalDate to) {
return (root, query, cb) -> {
if (query != null) query.distinct(true);
var receiversJoin = root.join("receivers", JoinType.LEFT);
var senderPredicate = cb.equal(root.get("sender"), person);
var receiverPredicate = cb.equal(receiversJoin, person);
var personPredicate = cb.or(senderPredicate, receiverPredicate);
var predicates = new ArrayList<>(List.of(personPredicate));
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
return cb.and(predicates.toArray(new Predicate[0]));
};
}
public long getIncompleteCount() {
return documentRepository.countByMetadataCompleteFalse();
}

View File

@@ -78,4 +78,8 @@ public class DomainException extends RuntimeException {
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
}
public static DomainException serviceUnavailable(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.SERVICE_UNAVAILABLE, message);
}
}

View File

@@ -122,6 +122,20 @@ public enum ErrorCode {
// --- Geschichten (Stories) ---
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
GESCHICHTE_NOT_FOUND,
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
JOURNEY_ITEM_NOT_FOUND,
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
JOURNEY_ITEM_POSITION_CONFLICT,
/** The journey already has the maximum allowed number of items (100). 400 */
JOURNEY_AT_CAPACITY,
/** The document is already present in this journey — duplicate items are not allowed. 409 */
JOURNEY_DOCUMENT_ALREADY_ADDED,
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
GESCHICHTE_TYPE_MISMATCH,
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
GESCHICHTE_TYPE_IMMUTABLE,
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
JOURNEY_NOTE_TOO_LONG,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */

View File

@@ -78,7 +78,14 @@ public class GlobalExceptionHandler {
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex));
String constraint = constraintNameOf(ex);
log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
if ("uq_journey_items_geschichte_position".equals(constraint)) {
// DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
return ResponseEntity.status(409)
.body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
"A position conflict was detected — another request modified this journey simultaneously"));
}
return ResponseEntity.badRequest()
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
}

View File

@@ -5,12 +5,14 @@ import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@@ -40,6 +42,12 @@ public class Geschichte {
@Builder.Default
private GeschichteStatus status = GeschichteStatus.DRAFT;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private GeschichteType type = GeschichteType.STORY;
@ManyToOne
@JoinColumn(name = "author_id")
private AppUser author;
@@ -51,12 +59,18 @@ public class Geschichte {
@Builder.Default
private Set<Person> persons = new HashSet<>();
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "geschichten_documents",
joinColumns = @JoinColumn(name = "geschichte_id"),
inverseJoinColumns = @JoinColumn(name = "document_id"))
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
// explicitly initialized inside the service transaction. getById() is
// @Transactional(readOnly=true) AND calls getItems().size() to force-init before return.
// list() must NOT serialize items at all — it returns a GeschichteSummary projection.
// This is the first List ("bag") collection on Geschichte — adding a second EAGER/
// fetch-joined List here will throw MultipleBagFetchException at boot.
@OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true,
fetch = FetchType.LAZY)
@OrderBy("position ASC")
@Builder.Default
private Set<Document> documents = new HashSet<>();
private List<JourneyItem> items = new ArrayList<>();
@CreationTimestamp
@Column(updatable = false)

View File

@@ -1,12 +1,14 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -28,9 +31,10 @@ import java.util.UUID;
public class GeschichteController {
private final GeschichteService geschichteService;
private final JourneyItemService journeyItemService;
@GetMapping
public List<Geschichte> list(
public List<GeschichteSummary> list(
@RequestParam(required = false) GeschichteStatus status,
@RequestParam(name = "personId", required = false) List<UUID> personIds,
@RequestParam(required = false) UUID documentId,
@@ -43,20 +47,20 @@ public class GeschichteController {
}
@GetMapping("/{id}")
public Geschichte getById(@PathVariable UUID id) {
return geschichteService.getById(id);
public GeschichteView getById(@PathVariable UUID id) {
return geschichteService.getView(id);
}
@PostMapping
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
Geschichte created = geschichteService.create(dto);
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
GeschichteView created = geschichteService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PatchMapping("/{id}")
@RequirePermission(Permission.BLOG_WRITE)
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
return geschichteService.update(id, dto);
}
@@ -66,4 +70,45 @@ public class GeschichteController {
geschichteService.delete(id);
return ResponseEntity.noContent().build();
}
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
@PostMapping("/{id}/items")
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<JourneyItemView> appendItem(
@PathVariable UUID id,
@RequestBody JourneyItemCreateDTO dto) {
JourneyItemView view = journeyItemService.append(id, dto);
return ResponseEntity.status(HttpStatus.CREATED).body(view);
}
@PatchMapping("/{id}/items/{itemId}")
@RequirePermission(Permission.BLOG_WRITE)
public JourneyItemView updateItemNote(
@PathVariable UUID id,
@PathVariable UUID itemId,
@RequestBody JourneyItemUpdateDTO dto) {
return journeyItemService.updateNote(id, itemId, dto);
}
@DeleteMapping("/{id}/items/{itemId}")
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Void> deleteItem(
@PathVariable UUID id,
@PathVariable UUID itemId) {
journeyItemService.delete(id, itemId);
return ResponseEntity.noContent().build();
}
@PutMapping("/{id}/items/reorder")
@RequirePermission(Permission.BLOG_WRITE)
@Operation(
summary = "Reorder journey items",
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
)
public List<JourneyItemView> reorderItems(
@PathVariable UUID id,
@RequestBody JourneyReorderDTO dto) {
return journeyItemService.reorder(id, dto);
}
}

View File

@@ -0,0 +1,29 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
/**
* Thin read-only service owning {@link GeschichteRepository}.
* Exists so that {@code JourneyItemService} can check Geschichte existence
* and load Geschichte instances without holding a direct reference to the
* Geschichte repository (cross-domain repository access is not allowed per
* layering rules).
*/
@Service
@RequiredArgsConstructor
public class GeschichteQueryService {
private final GeschichteRepository geschichteRepository;
public boolean existsById(UUID id) {
return geschichteRepository.existsById(id);
}
public Optional<Geschichte> findById(UUID id) {
return geschichteRepository.findById(id);
}
}

View File

@@ -1,12 +1,47 @@
package org.raddatz.familienarchiv.geschichte;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Repository
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
/**
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
*
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
*
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
*/
@Query("""
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
FROM Geschichte g
WHERE g.status = :effectiveStatus
AND (:authorId IS NULL OR g.author.id = :authorId)
AND (:personCount = 0 OR
(SELECT COUNT(DISTINCT p.id)
FROM Geschichte g2 JOIN g2.persons p
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
AND (:documentId IS NULL OR
EXISTS (SELECT 1 FROM JourneyItem ji
WHERE ji.geschichte = g AND ji.document.id = :documentId))
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
""")
List<GeschichteSummary> findSummaries(
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
@Param("authorId") UUID authorId,
@Param("personIds") Collection<UUID> personIds,
@Param("personCount") long personCount,
@Param("documentId") UUID documentId);
}

View File

@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
@@ -41,6 +36,7 @@ public class GeschichteService {
private final PersonService personService;
private final DocumentService documentService;
private final UserService userService;
private final JourneyItemService journeyItemService;
/**
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
@@ -60,6 +56,7 @@ public class GeschichteService {
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
}
@Transactional(readOnly = true)
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
@@ -72,24 +69,57 @@ public class GeschichteService {
return g;
}
@Transactional(readOnly = true)
public GeschichteView getView(UUID id) {
Geschichte g = getById(id);
List<JourneyItemView> items = journeyItemService.getItems(id);
return toView(g, items);
}
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
AppUser author = g.getAuthor();
GeschichteView.AuthorView authorView = null;
if (author != null) {
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
if (displayName.isBlank()) displayName = "[Unbekannt]";
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
}
Set<GeschichteView.PersonView> personViews = new HashSet<>();
for (Person p : g.getPersons()) {
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
}
return new GeschichteView(
g.getId(), g.getTitle(), g.getBody(),
g.getStatus(), g.getType(),
authorView, personViews,
items,
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
);
}
/**
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
* must be associated with every person id supplied. An empty or null list applies no
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
*
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
* LazyInitializationException on the non-transactional list path.
*/
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
Specification<Geschichte> spec = Specification.allOf(
GeschichteSpecifications.hasStatus(effective),
GeschichteSpecifications.hasAuthor(authorId),
GeschichteSpecifications.hasAllPersons(personIds),
GeschichteSpecifications.hasDocument(documentId),
GeschichteSpecifications.orderByDisplayDateDesc()
);
return geschichteRepository.findAll(spec, Sort.unsorted())
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
: personIds;
long personCount = (personIds == null) ? 0 : personIds.size();
return geschichteRepository
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
.stream()
.limit(safeLimit)
.toList();
@@ -97,46 +127,57 @@ public class GeschichteService {
// ─── Write API ───────────────────────────────────────────────────────────
// Write methods return GeschichteView, never the entity: Jackson serializes after
// the transaction closed, where the lazy items collection is a dead proxy.
// The view is assembled in-transaction, so no force-init tricks are needed.
@Transactional
public Geschichte create(GeschichteUpdateDTO dto) {
public GeschichteView create(GeschichteUpdateDTO dto) {
requireTitle(dto.getTitle());
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
Geschichte g = Geschichte.builder()
.title(dto.getTitle().trim())
.body(sanitize(dto.getBody()))
.body(bodyForType(type, dto.getBody()))
.status(GeschichteStatus.DRAFT)
.type(type)
.author(currentUser())
.persons(resolvePersons(dto.getPersonIds()))
.documents(resolveDocuments(dto.getDocumentIds()))
.build();
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
g.setStatus(GeschichteStatus.PUBLISHED);
g.setPublishedAt(LocalDateTime.now());
}
return geschichteRepository.save(g);
Geschichte saved = geschichteRepository.save(g);
// A freshly created Geschichte has no items by construction — items are only
// addable via the separate /items endpoints. Revisit if a create DTO ever
// accepts initial items.
return toView(saved, List.of());
}
@Transactional
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
if (dto.getType() != null && dto.getType() != g.getType()) {
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
"The type of a Geschichte cannot be changed after creation");
}
if (dto.getTitle() != null) {
requireTitle(dto.getTitle());
g.setTitle(dto.getTitle().trim());
}
if (dto.getBody() != null) {
g.setBody(sanitize(dto.getBody()));
g.setBody(bodyForType(g.getType(), dto.getBody()));
}
if (dto.getPersonIds() != null) {
g.setPersons(resolvePersons(dto.getPersonIds()));
}
if (dto.getDocumentIds() != null) {
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
}
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
applyStatusTransition(g, dto.getStatus());
}
return geschichteRepository.save(g);
Geschichte saved = geschichteRepository.save(g);
return toView(saved, journeyItemService.getItems(id));
}
@Transactional
@@ -166,6 +207,16 @@ public class GeschichteService {
}
}
/**
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
* JOURNEY intros are plain text: the reader renders them via Svelte text
* interpolation (never {@code {@html}}), so entity-encoding them here would
* corrupt content ("&" → "&amp;") and re-encode on every editor round-trip.
*/
private String bodyForType(GeschichteType type, String body) {
return type == GeschichteType.JOURNEY ? body : sanitize(body);
}
private String sanitize(String body) {
if (body == null) return null;
return BODY_SANITIZER.sanitize(body);
@@ -176,15 +227,6 @@ public class GeschichteService {
return new LinkedHashSet<>(personService.getAllById(ids));
}
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
Set<Document> out = new LinkedHashSet<>();
for (UUID id : ids) {
out.add(documentService.getDocumentById(id));
}
return out;
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {

View File

@@ -6,9 +6,6 @@ import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.springframework.data.jpa.domain.Specification;
@@ -48,12 +45,7 @@ public final class GeschichteSpecifications {
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
}
public static Specification<Geschichte> hasDocument(UUID documentId) {
return (root, query, cb) -> {
if (documentId == null) return null;
return cb.exists(documentSubquery(root, query, cb, documentId));
};
}
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
/**
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
@@ -84,14 +76,4 @@ public final class GeschichteSpecifications {
return sub;
}
private static Subquery<UUID> documentSubquery(
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
Subquery<UUID> sub = query.subquery(UUID.class);
Root<Geschichte> subRoot = sub.from(Geschichte.class);
Join<Geschichte, Document> documents = subRoot.join("documents");
sub.select(subRoot.get("id"))
.where(cb.equal(subRoot.get("id"), root.get("id")),
cb.equal(documents.get("id"), documentId));
return sub;
}
}

View File

@@ -0,0 +1,45 @@
package org.raddatz.familienarchiv.geschichte;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* List-projection for the /api/geschichten grid. Never carries items — avoids
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
* Mirrors the PersonSummaryDTO precedent.
*
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
* publishedAt, status, type). Does NOT carry items or persons.
*/
public interface GeschichteSummary {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID getId();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String getTitle();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
GeschichteStatus getStatus();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
GeschichteType getType();
/** Nested closed projection — exposes only the fields the grid card needs. */
AuthorSummary getAuthor();
LocalDateTime getPublishedAt();
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime getUpdatedAt();
String getBody();
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
interface AuthorSummary {
String getFirstName();
String getLastName();
}
}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.geschichte;
public enum GeschichteType {
STORY,
JOURNEY
}

View File

@@ -1,7 +1,6 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.Data;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import java.util.List;
import java.util.UUID;
@@ -16,6 +15,6 @@ public class GeschichteUpdateDTO {
private String title;
private String body;
private GeschichteStatus status;
private GeschichteType type;
private List<UUID> personIds;
private List<UUID> documentIds;
}

View File

@@ -0,0 +1,41 @@
package org.raddatz.familienarchiv.geschichte;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Detail-view response for GET /api/geschichten/{id}. Assembled by
* GeschichteService — never the raw entity (author AppUser graph must not leak).
* items is always present (both STORY and JOURNEY); empty list for stories with no items.
*/
public record GeschichteView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
String body,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteStatus status,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteType type,
AuthorView author,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<PersonView> persons,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<JourneyItemView> items,
LocalDateTime publishedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt
) {
/** Summarised author — exposes only id and displayName, never email or group memberships. */
public record AuthorView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName
) {}
/** Summarised person — exposes only id, firstName, and lastName. No admin-only fields. */
public record PersonView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
String firstName,
String lastName
) {}
}

View File

@@ -0,0 +1,22 @@
package org.raddatz.familienarchiv.geschichte;
/**
* Utility for joining a person's first and last name into a display string.
* Centralises the logic that was previously duplicated across GeschichteService
* and JourneyItemService.
*/
public class PersonNameFormatter {
private PersonNameFormatter() {
// utility class — no instances
}
public static String join(String firstName, String lastName) {
String first = firstName != null ? firstName.trim() : "";
String last = lastName != null ? lastName.trim() : "";
if (first.isEmpty() && last.isEmpty()) return "";
if (first.isEmpty()) return last;
if (last.isEmpty()) return first;
return first + " " + last;
}
}

View File

@@ -0,0 +1,23 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.UUID;
/**
* Lean read-model view of a Document for embedding in JourneyItemView.
* Built by JourneyItemService.toSummary(Document) — never serialised from
* a JPA entity to avoid LazyInitializationException and tag-color overhead.
*/
public record DocumentSummary(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
LocalDate documentDate,
LocalDate documentDateEnd,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision datePrecision,
String senderName,
String receiverName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Integer receiverCount
) {}

View File

@@ -0,0 +1,51 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import java.util.UUID;
@Entity
@Table(name = "journey_items")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class JourneyItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "geschichte_id", nullable = false)
@JsonIgnore
private Geschichte geschichte;
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
// — the editor is responsible for keeping them distinct.
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int position;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id")
@JsonIgnore
private Document document;
// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
// renderer MUST escape this; only Svelte {note} is auto-safe.
@Column(columnDefinition = "TEXT")
private String note;
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
// Exposing only the UUID prevents circular references and large nested payloads.
public UUID getDocumentId() {
return document != null ? document.getId() : null;
}
}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.UUID;
/** Input for POST /api/geschichten/{id}/items. Both fields optional; at least one must be present. */
@Data
public class JourneyItemCreateDTO {
private UUID documentId;
private String note;
}

View File

@@ -0,0 +1,54 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@Repository
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
/** Returns items ordered by position ASC for the read-model assembly path. */
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
/** Returns only the IDs — used for set-equality check in reorder. */
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
/** MAX position for computing the next append position; returns empty when journey has no items. */
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
long countByGeschichteId(UUID geschichteId);
/**
* Dedup guard: true when the document is already linked to this journey.
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
* getter on JourneyItem makes Spring Data resolve the derived path as a
* direct {@code documentId} attribute, which Hibernate cannot map.
*/
@Query("""
SELECT COUNT(i) > 0 FROM JourneyItem i
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
""")
boolean existsByGeschichteIdAndDocumentId(
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
/**
* Loads journey items with their linked Document in a single JOIN FETCH query,
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
* lazily for each item. Items without a document (note-only) are included via
* LEFT JOIN. Ordered by position ASC.
*/
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
}

View File

@@ -0,0 +1,268 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class JourneyItemService {
static final int MAX_ITEMS = 100;
static final int POSITION_STEP = 10;
// 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
static final int MAX_NOTE_LENGTH = 2000;
private final JourneyItemRepository journeyItemRepository;
private final GeschichteQueryService geschichteQueryService;
private final DocumentService documentService;
private final AuditService auditService;
private final UserService userService;
@Transactional
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
Geschichte g = geschichteQueryService.findById(geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
"Journey not found: " + geschichteId));
if (g.getType() != GeschichteType.JOURNEY) {
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_MISMATCH,
"Journey items can only be added to a JOURNEY-type Geschichte");
}
long count = journeyItemRepository.countByGeschichteId(geschichteId);
if (count >= MAX_ITEMS) {
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
"Journey has reached the maximum of 100 items");
}
String note = normalizeNote(dto.getNote());
if (dto.getDocumentId() == null && note == null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"At least one of documentId or note must be provided");
}
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
Document doc = null;
if (dto.getDocumentId() != null) {
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
"Document already in journey: " + dto.getDocumentId());
}
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
}
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
.map(max -> max + POSITION_STEP)
.orElse(POSITION_STEP);
JourneyItem item = JourneyItem.builder()
.geschichte(g)
.position(nextPosition)
.document(doc)
.note(note)
.build();
// saveAndFlush so the partial unique index on (geschichte_id, document_id)
// fires here, not at commit — two concurrent appends can both pass the
// exists() pre-check above, and the index is the atomic backstop (V74).
JourneyItem saved;
try {
saved = journeyItemRepository.saveAndFlush(item);
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
"Document already in journey: " + dto.getDocumentId());
}
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
return toView(saved);
}
@Transactional
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
// null = field absent from JSON → no-op
Optional<String> noteField = dto.getNote();
if (noteField == null) {
return toView(item);
}
String note = normalizeNote(noteField.orElse(null));
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
if (note == null && item.getDocumentId() == null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Cannot clear note on an item that has no linked document");
}
item.setNote(note);
JourneyItem saved = journeyItemRepository.save(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", itemId));
return toView(saved);
}
@Transactional
public void delete(UUID geschichteId, UUID itemId) {
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
journeyItemRepository.delete(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", itemId));
}
@Transactional
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
if (!geschichteQueryService.existsById(geschichteId)) {
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
"Journey not found: " + geschichteId);
}
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Duplicate item IDs in reorder request");
}
if (!existingIds.equals(new HashSet<>(requestedIds))) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Requested item IDs do not match the journey's existing items");
}
if (requestedIds.isEmpty()) {
return List.of();
}
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
Map<UUID, JourneyItem> itemMap = new HashMap<>();
for (JourneyItem item : items) {
itemMap.put(item.getId(), item);
}
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
for (int i = 0; i < requestedIds.size(); i++) {
JourneyItem item = itemMap.get(requestedIds.get(i));
item.setPosition((i + 1) * POSITION_STEP);
toSave.add(item);
}
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
return reordered.stream().map(this::toView).toList();
}
public List<JourneyItemView> getItems(UUID geschichteId) {
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
.stream().map(this::toView).toList();
}
DocumentSummary toSummary(Document doc) {
String senderName = buildSenderName(doc);
Set<Person> receivers = doc.getReceivers();
String receiverName = buildCanonicalReceiverName(receivers);
return new DocumentSummary(
doc.getId(),
doc.getTitle(),
doc.getDocumentDate(),
doc.getMetaDateEnd(),
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
senderName,
receiverName,
receivers != null ? receivers.size() : 0
);
}
JourneyItemView toView(JourneyItem item) {
DocumentSummary docSummary = null;
Document doc = item.getDocument();
if (doc != null) {
docSummary = toSummary(doc);
}
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
}
private static String buildSenderName(Document doc) {
Person sender = doc.getSender();
if (sender != null) {
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
if (!name.isBlank()) return name;
}
String senderText = doc.getSenderText();
return (senderText != null && !senderText.isBlank()) ? senderText : null;
}
private static String buildCanonicalReceiverName(Set<Person> receivers) {
if (receivers == null || receivers.isEmpty()) return null;
return receivers.stream()
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
.map(p -> {
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
return name.isBlank() ? null : name;
})
.orElse(null);
}
private static String normalizeNote(String raw) {
if (raw == null || raw.isBlank()) return null;
return raw.trim();
}
private static String sortKey(String s) {
return s != null ? s : "";
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
return userService.findByEmail(auth.getName());
}
}

View File

@@ -0,0 +1,19 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.Optional;
/**
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
* Three-way semantics via Optional<String>:
* null → field absent from JSON → leave note unchanged
* Optional.empty() → {"note": null} → clear the note
* Optional.of("x") → {"note": "x"} → set the note
*
* Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
*/
@Data
public class JourneyItemUpdateDTO {
private Optional<String> note = null;
}

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
/**
* Read-model response for a JourneyItem. Never the JPA entity (which has a
* Geschichte back-reference that would leak / hit LazyInitializationException).
*/
public record JourneyItemView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
DocumentSummary document,
String note
) {}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.List;
import java.util.UUID;
/** Input for PUT /api/geschichten/{id}/items/reorder. */
@Data
public class JourneyReorderDTO {
private List<UUID> itemIds;
}

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.person;
import java.util.List;
/**
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
* strength. {@code direct} = every query token is a whole-token match across the person's name
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
*/
public record NameMatches(List<Person> direct, List<Person> partial) {
}

View File

@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"ORDER BY p.lastName ASC, p.firstName ASC")
List<Person> searchByName(@Param("query") String query);
@@ -29,14 +30,36 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Stammbaum-Knoten: alle Personen mit family_member = true.
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
// duplicates: source_ref is the stable identity (ADR-025/033), alias is editable. Do NOT
// add a unique(lower(alias)) constraint — see ADR-033.
Optional<Person> findByAlias(String alias);
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-033.
List<Person> findAllByAliasIgnoreCase(String alias);
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
Optional<Person> findBySourceRef(String sourceRef);
// Exact first+last name match, used for filename-based sender lookup
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
// Exact-case first+last name match — the first step of filename-based sender resolution.
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
// pull a last-name-only row in as a sender (a provenance defect). See ADR-033.
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
@Param("lastName") String lastName);
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
// first name resolves to no match (not first_name IS NULL widening). See ADR-033.
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
@Param("lastName") String lastName);
// --- PersonSummaryDTO with document count ---

View File

@@ -1,8 +1,15 @@
package org.raddatz.familienarchiv.person;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.lang.Nullable;
@@ -23,11 +30,20 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class PersonService {
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
// past position 10 among partials is never discarded.
private static final int MAX_TOKENS = 8;
private static final int MAX_CANDIDATES = 10;
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
@@ -98,6 +114,96 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public List<Person> findByDisplayNameContaining(String fragment) {
return personRepository.searchByName(fragment);
}
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
// drop empties. Applied symmetrically to the query and to every candidate name component so
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
static Set<String> tokenize(String raw) {
if (raw == null || raw.isBlank()) {
return Set.of();
}
LinkedHashSet<String> tokens = new LinkedHashSet<>();
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
if (!part.isEmpty()) {
tokens.add(part);
}
}
return tokens;
}
/**
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
* are reachable during classification (see ADR-022).
*/
@Transactional(readOnly = true)
public NameMatches resolveByName(String name) {
Set<String> queryTokens = capTokens(tokenize(name));
if (queryTokens.isEmpty()) {
log.debug("resolveByName outcome=no-match tokens=0");
return new NameMatches(List.of(), List.of());
}
return classify(fetchPool(queryTokens), queryTokens);
}
private Set<String> capTokens(Set<String> tokens) {
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
}
private List<Person> fetchPool(Set<String> queryTokens) {
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
for (String token : queryTokens) {
for (Person candidate : findByDisplayNameContaining(token)) {
pool.putIfAbsent(candidate.getId(), candidate);
}
}
return new ArrayList<>(pool.values());
}
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
List<Person> direct = new ArrayList<>();
List<Person> partial = new ArrayList<>();
for (Person candidate : pool) {
if (personTokens(candidate).containsAll(queryTokens)) {
direct.add(candidate);
} else {
partial.add(candidate);
}
}
List<Person> cappedDirect = cap(direct);
List<Person> cappedPartial = cap(partial);
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
return new NameMatches(cappedDirect, cappedPartial);
}
private static Set<String> personTokens(Person person) {
Set<String> tokens = new LinkedHashSet<>();
tokens.addAll(tokenize(person.getFirstName()));
tokens.addAll(tokenize(person.getLastName()));
tokens.addAll(tokenize(person.getAlias()));
tokens.addAll(tokenize(person.getTitle()));
for (PersonNameAlias alias : person.getNameAliases()) {
tokens.addAll(tokenize(alias.getFirstName()));
tokens.addAll(tokenize(alias.getLastName()));
}
return tokens;
}
private static List<Person> cap(List<Person> people) {
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
}
private static String outcome(List<Person> direct, List<Person> partial) {
if (direct.size() == 1) return "direct=1";
if (direct.size() >= 2) return "direct>=2";
if (!partial.isEmpty()) return "partial-only";
return "no-match";
}
public List<Person> findAllFamilyMembers() {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
@@ -110,7 +216,19 @@ public class PersonService {
}
public Optional<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
// Same scope as findOrCreateByAlias (#731): a case-collision resolves without throwing;
// two byte-identical same-case persons are an out-of-scope data anomaly the exact
// Optional below would surface as the opaque INTERNAL_ERROR, not a wrong sender.
Optional<Person> exact = personRepository.findByFirstNameAndLastName(firstName, lastName);
if (exact.isPresent()) return exact;
List<Person> caseInsensitive =
personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
// Deliberate divergence from findOrCreateByAlias: an ambiguous filename leaves the sender
// UNSET rather than picking the lowest id. The archive's value is correct provenance — a
// confidently-wrong pre-filled "Hans Müller" is worse than an empty field, because a
// reviewer won't re-check a pre-filled value. Do NOT "consistency-clean" this into the
// lowest-id fallback. See ADR-033.
return caseInsensitive.size() == 1 ? Optional.of(caseInsensitive.get(0)) : Optional.empty();
}
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
@@ -125,32 +243,45 @@ public class PersonService {
PersonType type = PersonTypeClassifier.classify(alias);
if (type == PersonType.SKIP) return null;
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
return personRepository.save(Person.builder()
.alias(alias)
.lastName(alias)
.personType(type)
.build());
}
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
// are a true data anomaly out of scope here; the exact Optional below would surface that
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
Optional<Person> exact = personRepository.findByAlias(alias);
if (exact.isPresent()) return exact.get(); // exact-case wins
List<Person> caseInsensitive = personRepository.findAllByAliasIgnoreCase(alias);
if (!caseInsensitive.isEmpty()) {
return caseInsensitive.stream().min(Comparator.comparing(Person::getId)).orElseThrow(); // deterministic tie-break — list is non-empty, never throws
}
PersonNameParser.SplitName split = PersonNameParser.split(alias);
Person person = personRepository.save(Person.builder()
// Create-when-absent: institution/group keep the full label in lastName; a person name
// is split and a maiden name (geb. …) becomes a MAIDEN_NAME alias.
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
return personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.lastName(alias)
.personType(type)
.build());
if (split.maidenName() != null) {
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
aliasRepository.save(PersonNameAlias.builder()
.person(person)
.lastName(split.maidenName())
.type(PersonNameAliasType.MAIDEN_NAME)
.sortOrder(nextSortOrder)
.build());
}
return person;
});
}
PersonNameParser.SplitName split = PersonNameParser.split(alias);
Person person = personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
if (split.maidenName() != null) {
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
aliasRepository.save(PersonNameAlias.builder()
.person(person)
.lastName(split.maidenName())
.type(PersonNameAliasType.MAIDEN_NAME)
.sortOrder(nextSortOrder)
.build());
}
return person;
}
/**

View File

@@ -20,8 +20,9 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
| `findAll(String q)` | document, dashboard | List all persons |
| `findByName(String firstName, String lastName)` | document | Typeahead search |
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
| `findCorrespondents()` | document | Correspondent list for conversation filter |
| `count()` | dashboard | Total person count for stats |

View File

@@ -46,6 +46,10 @@ public class TagService {
return enrichWithRelatives(matched);
}
public List<Tag> findByNameContaining(String fragment) {
return tagRepository.findByNameContainingIgnoreCase(fragment);
}
public Tag getById(UUID id) {
return tagRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));

View File

@@ -11,3 +11,4 @@ springdoc:
swagger-ui:
enabled: true
path: /swagger-ui.html

View File

@@ -0,0 +1,73 @@
-- Production pre-requisite — run BEFORE applying this migration:
-- docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-- -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"'
-- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
-- --table=geschichten_documents \
-- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql'
-- Take the dump even if geschichten_documents is empty — it captures the table DEFINITION
-- for emergency reconstruction. The DROP TABLE is the only irreversible step; the
-- INSERT...SELECT is a no-op when there is no data. No DDL rollback path exists after commit.
--
-- REVERSE PROCEDURE (if V72 must be rolled back): restore the pre-V72 dump, then re-derive
-- the junction from the new table:
-- INSERT INTO geschichten_documents (geschichte_id, document_id)
-- SELECT geschichte_id, document_id FROM journey_items WHERE document_id IS NOT NULL;
-- Note: the reconstructed junction FK is ON DELETE CASCADE per the original V58
-- (NOT the new SET NULL of journey_items). Domain FKs target app_users (post-V60) —
-- do NOT hand-type V58's verbatim "REFERENCES users" DDL nor copy journey_items' SET NULL
-- into the reconstructed junction.
--
-- ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order
-- existed. Ordering by meta_date is a plausible default a Lesereise lets curators
-- re-sequence. This is not a requirement; it is the best available approximation.
--
-- ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block;
-- this block visibly degrades to generic links (loss of per-document title AND date) for ALL
-- current readers during the stub window. Accepted because the reader follow-on is the
-- next-priority blocking dependency.
-- Step 1: Add type discriminator column to geschichten
ALTER TABLE geschichten
ADD COLUMN type VARCHAR(50) DEFAULT 'STORY' NOT NULL;
-- Step 2: Create journey_items table
CREATE TABLE journey_items (
id UUID NOT NULL DEFAULT gen_random_uuid(),
geschichte_id UUID NOT NULL,
position INT NOT NULL,
document_id UUID,
note TEXT,
CONSTRAINT pk_journey_items PRIMARY KEY (id),
CONSTRAINT fk_journey_items_geschichte
FOREIGN KEY (geschichte_id) REFERENCES geschichten(id) ON DELETE CASCADE,
CONSTRAINT fk_journey_items_document
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE SET NULL,
CONSTRAINT chk_journey_item_not_empty
CHECK (document_id IS NOT NULL OR note IS NOT NULL)
);
-- Step 3: Index for ordered retrieval by geschichte + position
CREATE INDEX idx_journey_items_geschichte_position
ON journey_items (geschichte_id, position ASC);
-- Step 4: Migrate geschichten_documents → journey_items
-- Positions are multiples of 1000 (headroom for drag-reorder).
-- Ordered by meta_date ASC NULLS LAST, then documents.id ASC as deterministic tiebreaker.
-- SELECT DISTINCT guards against duplicate junction rows producing duplicate journey items.
INSERT INTO journey_items (id, geschichte_id, position, document_id)
SELECT
gen_random_uuid(),
gd.geschichte_id,
(ROW_NUMBER() OVER (
PARTITION BY gd.geschichte_id
ORDER BY d.meta_date ASC NULLS LAST, d.id ASC
) * 1000)::INT AS position,
gd.document_id
FROM (
SELECT DISTINCT geschichte_id, document_id
FROM geschichten_documents
) gd
LEFT JOIN documents d ON d.id = gd.document_id;
-- Step 5: Drop the old junction table (irreversible — take the pg_dump first)
DROP TABLE geschichten_documents;

View File

@@ -0,0 +1,19 @@
-- Adds the two constraints that V72 deferred:
-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row).
-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
-- in transaction mode — correct today; a future switch to statement-level would silently
-- break deferred checking at COMMIT).
-- 2. CHECK (position > 0) — defense against off-by-one in the append path.
--
-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
-- Do NOT add executeInTransaction=false or any callback that splits this migration.
ALTER TABLE journey_items
ADD CONSTRAINT uq_journey_items_geschichte_position
UNIQUE (geschichte_id, position)
DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE journey_items
ADD CONSTRAINT chk_journey_item_position
CHECK (position > 0);

View File

@@ -0,0 +1,21 @@
-- Two constraints the service-level checks need as atomic backstops:
--
-- 1. Partial unique index on (geschichte_id, document_id): the append dedup
-- guard is a check-then-insert (existsByGeschichteIdAndDocumentId), so two
-- concurrent appends of the same document can both pass the pre-check.
-- The index rejects the second INSERT; JourneyItemService.append translates
-- the DataIntegrityViolationException into the same 409
-- JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check.
-- Partial (WHERE document_id IS NOT NULL) — note-only interludes must not collide.
--
-- 2. CHECK on note length: mirrors chk_text_length on transcription_blocks.
-- 2000 is the spec'd limit — JourneyItemService.MAX_NOTE_LENGTH, the frontend
-- maxlength, and the i18n error message all agree (#793).
CREATE UNIQUE INDEX uq_journey_items_geschichte_document
ON journey_items (geschichte_id, document_id)
WHERE document_id IS NOT NULL;
ALTER TABLE journey_items
ADD CONSTRAINT chk_journey_item_note_length
CHECK (note IS NULL OR length(note) <= 2000);

View File

@@ -131,6 +131,28 @@ class DocumentLazyLoadingTest {
.doesNotThrowAnyException();
}
@Test
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
// q + default sort + no other filters → the relevance fast path
// (relevanceSortedPageFromSql), which loads documents by id outside any
// transaction and must still deliver an initialized tags collection.
Person sender = savedPerson("Hans", "FtSender");
Tag tag = savedTag("FtTag");
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
SearchFilters textOnly = new SearchFilters(
"Walter", null, null, null, null, null, null, null, null, false);
DocumentSearchResult result = documentService.searchDocuments(
textOnly, null, "DESC", PageRequest.of(0, 10));
assertThat(result.totalElements()).isEqualTo(1);
assertThatCode(() ->
result.items().forEach(i -> i.tags().size()))
.doesNotThrowAnyException();
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
}
@Test
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SsSender");

View File

@@ -624,4 +624,88 @@ class DocumentRepositoryTest {
.reviewed(reviewed)
.build();
}
// ─── searchDocumentsByPersonId (via Specification) ───────────────────────
private Page<Document> searchByPerson(Person person, LocalDate from, LocalDate to) {
Specification<Document> spec = (root, query, cb) -> {
if (query != null) query.distinct(true);
var receiversJoin = root.join("receivers", jakarta.persistence.criteria.JoinType.LEFT);
var personPredicate = cb.or(
cb.equal(root.get("sender"), person),
cb.equal(receiversJoin, person));
var predicates = new java.util.ArrayList<>(java.util.List.of(personPredicate));
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
};
return documentRepository.findAll(spec, PageRequest.of(0, 10));
}
@Test
void searchByPersonSpec_returnsDocument_whenPersonIsSender() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("Senderbrief").originalFilename("sender.pdf")
.status(DocumentStatus.UPLOADED).sender(person).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
}
@Test
void searchByPersonSpec_returnsDocument_whenPersonIsReceiver() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("Empfängerbrief").originalFilename("receiver.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(new java.util.HashSet<>(List.of(person))).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
}
@Test
void searchByPersonSpec_returnsDocumentOnce_whenPersonIsBothSenderAndReceiver() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("SenderEmpfänger").originalFilename("both.pdf")
.status(DocumentStatus.UPLOADED).sender(person)
.receivers(new java.util.HashSet<>(List.of(person))).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getId()).isEqualTo(doc.getId());
}
@Test
void searchByPersonSpec_excludesDocuments_outsideDateRange() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document inside = documentRepository.save(Document.builder()
.title("Innen").originalFilename("inside.pdf").status(DocumentStatus.UPLOADED)
.sender(person).documentDate(LocalDate.of(1918, 6, 15)).build());
documentRepository.save(Document.builder()
.title("Außen").originalFilename("outside.pdf").status(DocumentStatus.UPLOADED)
.sender(person).documentDate(LocalDate.of(1920, 1, 1)).build());
Page<Document> result = searchByPerson(person, LocalDate.of(1914, 1, 1), LocalDate.of(1918, 12, 31));
assertThat(result.getContent()).extracting(Document::getId).containsExactly(inside.getId());
}
@Test
void searchByPersonSpec_returnsEmpty_whenNoMatchingDocuments() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Person other = personRepository.save(Person.builder().lastName("Braun").build());
documentRepository.save(Document.builder()
.title("Fremder Brief").originalFilename("other.pdf")
.status(DocumentStatus.UPLOADED).sender(other).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).isEmpty();
}
}

View File

@@ -81,7 +81,7 @@ class DocumentServiceSortTest {
UUID id1 = UUID.randomUUID();
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any()))
when(documentRepository.findByIdIn(any()))
.thenReturn(List.of(doc(id1)));
documentService.searchDocuments(
@@ -101,7 +101,7 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),

View File

@@ -2166,7 +2166,7 @@ class DocumentServiceTest {
List<Object[]> ftsRows = new java.util.ArrayList<>();
ftsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
@@ -2202,7 +2202,7 @@ class DocumentServiceTest {
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(

View File

@@ -12,6 +12,7 @@ import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@@ -37,6 +38,30 @@ class GlobalExceptionHandlerTest {
}
}
@Test
void handleGeneric_incorrectResultSize_staysOpaque_noHibernateOrRowCountLeak() {
// #731: before the fix, a case-colliding alias/name made Hibernate throw
// NonUniqueResultException → IncorrectResultSizeDataAccessException, which has no
// dedicated handler and falls through to handleGeneric. The fix removes the throw, but
// this pins the handler: a stray one must stay opaque — no Hibernate class name, no SQL,
// no "2 results were returned" row count reaching the client (CWE-209).
IncorrectResultSizeDataAccessException ex = new IncorrectResultSizeDataAccessException(
"query did not return a unique result: 2 results were returned", 1, 2);
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
assertThat(response.getStatusCode().value()).isEqualTo(500);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
assertThat(response.getBody().message())
.isEqualTo("An unexpected error occurred")
.doesNotContain("results were returned")
.doesNotContain("NonUnique")
.doesNotContain("IncorrectResultSize");
}
}
@Test
void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() {
// A DataIntegrityViolationException carries the constraint name + SQL in its message;

View File

@@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean
GeschichteService geschichteService;
@MockitoBean
CustomUserDetailsService customUserDetailsService;
@MockitoBean GeschichteService geschichteService;
@MockitoBean JourneyItemService journeyItemService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/geschichten ────────────────────────────────────────────────
@@ -65,7 +64,7 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void list_returns200_forReader() throws Exception {
when(geschichteService.list(any(), any(), any(), anyInt()))
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
.thenReturn(List.of(summaryStub("Story A")));
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isOk())
@@ -107,7 +106,7 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void getById_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
mockMvc.perform(get("/api/geschichten/{id}", id))
.andExpect(status().isOk())
@@ -119,7 +118,7 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id))
when(geschichteService.getView(id))
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
mockMvc.perform(get("/api/geschichten/{id}", id))
@@ -151,7 +150,7 @@ class GeschichteControllerTest {
void create_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
.thenReturn(draft(id, "New"));
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New");
@@ -179,7 +178,7 @@ class GeschichteControllerTest {
void update_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(published(id, "Updated"));
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@@ -208,31 +207,202 @@ class GeschichteControllerTest {
verify(geschichteService).delete(id);
}
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
@Test
void appendItem_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"Note\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(itemId.toString()))
.andExpect(jsonPath("$.position").value(10));
}
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenReturn(itemViewStub(itemId, 10, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"Updated\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.note").value("Updated"));
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenReturn(itemViewStub(itemId, 10, null));
// Raw JSON — local objectMapper lacks JsonNullableModule
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\": null}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.note").value(nullValue()));
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_returns404_whenItemNotFound() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
}
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void deleteItem_returns204_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
.andExpect(status().isNoContent());
verify(journeyItemService).delete(id, itemId);
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void deleteItem_returns404_whenItemNotFound() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
.when(journeyItemService).delete(id, itemId);
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
}
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemIds\":[]}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void reorderItems_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
}
// ─── error mapping ───────────────────────────────────────────────────────
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns409_on_position_conflict() throws Exception {
UUID id = UUID.randomUUID();
when(journeyItemService.append(eq(id), any()))
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte published(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.body("<p>x</p>")
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
private JourneyItemView itemViewStub(UUID id, int position, String note) {
return new JourneyItemView(id, position, null, note);
}
private Geschichte draft(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.status(GeschichteStatus.DRAFT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
private GeschichteView viewStub(UUID id, String title) {
return viewStub(id, title, GeschichteStatus.PUBLISHED);
}
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
return new GeschichteView(id, title, "<p>x</p>",
status, GeschichteType.STORY,
null, new HashSet<>(), List.of(),
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
}
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
private GeschichteSummary summaryStub(String title) {
return new GeschichteSummary() {
public UUID getId() { return UUID.randomUUID(); }
public String getTitle() { return title; }
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
public GeschichteType getType() { return GeschichteType.STORY; }
public AuthorSummary getAuthor() { return null; }
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
public String getBody() { return null; }
};
}
}

View File

@@ -0,0 +1,298 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.raddatz.familienarchiv.user.UserGroup;
import org.raddatz.familienarchiv.user.UserGroupRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
*
* <p>No {@code @Transactional} at class level — that would keep a session open and
* mask LazyInitializationException caused by open-in-view: false. Each test seeds data
* directly via repositories and relies on the service's own transaction boundaries.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class GeschichteHttpTest {
@LocalServerPort int port;
@MockitoBean S3Client s3Client;
@Autowired GeschichteRepository geschichteRepository;
@Autowired AppUserRepository appUserRepository;
@Autowired UserGroupRepository userGroupRepository;
@Autowired PasswordEncoder passwordEncoder;
private RestTemplate http;
private String baseUrl;
private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
private static final String WRITER_PASSWORD = "pass!Geschichte1";
@BeforeEach
void setUp() {
http = noThrowRestTemplate();
baseUrl = "http://localhost:" + port;
geschichteRepository.deleteAll();
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete);
appUserRepository.save(AppUser.builder()
.email(WRITER_EMAIL)
.password(passwordEncoder.encode(WRITER_PASSWORD))
.build());
}
// ─── GET /api/geschichten ────────────────────────────────────────────────
@Test
void list_returns_200_and_empty_array_when_no_stories_exist() {
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten", HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo("[]");
}
@Test
void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
// Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
// Hibernate.initialize in getById() this would 500. list() uses a projection so it
// must also never touch items.
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte journey = Geschichte.builder()
.title("Reise durch die Briefe")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
JourneyItem item = JourneyItem.builder()
.geschichte(journey)
.position(1000)
.note("Einleitung")
.build();
journey.getItems().add(item);
geschichteRepository.save(journey);
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten", HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("Reise durch die Briefe");
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@Test
void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
// This test is the canonical guard against LazyInitializationException.
// open-in-view: false means the Hibernate session is closed when Jackson serializes.
// GeschichteService.getById() must initialize items inside its @Transactional boundary.
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte journey = Geschichte.builder()
.title("Familiengeschichte")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
JourneyItem note = JourneyItem.builder()
.geschichte(journey).position(1000).note("Prolog").build();
JourneyItem note2 = JourneyItem.builder()
.geschichte(journey).position(2000).note("Epilog").build();
journey.getItems().add(note);
journey.getItems().add(note2);
Geschichte saved = geschichteRepository.save(journey);
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("Familiengeschichte")
.contains("Prolog")
.contains("Epilog");
}
@Test
void getById_returns_404_for_unknown_id() {
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
}
@Test
void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte draft = Geschichte.builder()
.title("Geheimer Entwurf")
.status(GeschichteStatus.DRAFT)
.author(writer)
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
Geschichte saved = geschichteRepository.save(draft);
// Writer lacks explicit BLOG_WRITE permission in the app_users table,
// so from the service's perspective they're a reader.
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
}
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
@Test
void update_returns_200_and_serializes_items_open_in_view_false() {
// Canonical guard for the write path: PATCH must not 500 when the response
// is serialized after the service transaction closed. The raw entity carries
// a dead lazy items proxy at that point — the endpoint must answer with a
// view assembled inside the transaction.
AppUser writer = blogWriter();
Geschichte journey = Geschichte.builder()
.title("Reise vor dem Umbenennen")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.author(writer)
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
journey.getItems().add(JourneyItem.builder()
.geschichte(journey).position(1000).note("Prolog").build());
Geschichte saved = geschichteRepository.save(journey);
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("Reise nach dem Umbenennen")
.contains("Prolog");
}
// ─── helpers ─────────────────────────────────────────────────────────────
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
private AppUser blogWriter() {
UserGroup group = userGroupRepository.save(UserGroup.builder()
.name("HttpTest-BlogWriters")
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
.build());
return appUserRepository.save(AppUser.builder()
.email(BLOG_WRITER_EMAIL)
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
.groups(new HashSet<>(Set.of(group)))
.build());
}
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
private HttpHeaders csrfJsonHeaders(String sessionId) {
String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf);
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private String loginAsWriter() {
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
}
private String loginAs(String email, String password) {
String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf);
String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}";
ResponseEntity<String> resp = http.postForEntity(
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
return extractFaSessionCookie(resp);
}
private HttpHeaders sessionHeaders(String sessionId) {
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId);
return headers;
}
private String extractFaSessionCookie(ResponseEntity<?> response) {
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
if (setCookieHeader == null) return "";
return setCookieHeader.stream()
.filter(c -> c.startsWith("fa_session="))
.map(c -> c.split(";")[0].substring("fa_session=".length()))
.findFirst()
.orElse("");
}
private RestTemplate noThrowRestTemplate() {
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false;
}
});
return template;
}
}

View File

@@ -0,0 +1,262 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
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 java.time.LocalDateTime;
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 GeschichteListProjectionTest {
@Autowired GeschichteRepository geschichteRepository;
@Autowired AppUserRepository appUserRepository;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@Autowired JourneyItemRepository journeyItemRepository;
AppUser author;
AppUser otherAuthor;
@BeforeEach
void setUp() {
geschichteRepository.deleteAll();
author = appUserRepository.save(AppUser.builder()
.email("author@test").password("pw").build());
otherAuthor = appUserRepository.save(AppUser.builder()
.email("other@test").password("pw").build());
}
// ─── findSummaries returns only the requested status ─────────────────────
@Test
void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
geschichteRepository.save(published("Veröffentlicht", author));
geschichteRepository.save(draft("Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
}
@Test
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
// projection must carry it for drafts, where publishedAt is null.
geschichteRepository.save(draft("Mein Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getUpdatedAt()).isNotNull();
}
@Test
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
geschichteRepository.save(draft("Nur Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).isEmpty();
}
// ─── AuthorSummary nested projection ─────────────────────────────────────
@Test
void findSummaries_exposes_nested_author_names_but_never_email() {
AppUser richAuthor = appUserRepository.save(AppUser.builder()
.firstName("Franz").lastName("Raddatz")
.email("franz@raddatz.de").password("pw").build());
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1);
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
assertThat(a.getFirstName()).isEqualTo("Franz");
assertThat(a.getLastName()).isEqualTo("Raddatz");
// Design rule (GeschichteView.AuthorView javadoc): author projections never
// expose email or group memberships to readers.
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
.extracting(java.lang.reflect.Method::getName)
.doesNotContain("getEmail");
}
// ─── GeschichteType is exposed ────────────────────────────────────────────
@Test
void findSummaries_exposes_type_field() {
Geschichte journey = Geschichte.builder()
.title("Eine Reise")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(author)
.publishedAt(LocalDateTime.now())
.build();
geschichteRepository.save(journey);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
}
// ─── authorId filter (own-drafts gate) ───────────────────────────────────
@Test
void findSummaries_with_authorId_returns_only_own_drafts() {
geschichteRepository.save(draft("Mein Entwurf", author));
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
}
// ─── personCount = 0 → no person filter ──────────────────────────────────
@Test
void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
geschichteRepository.save(published("A", author));
geschichteRepository.save(published("B", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(2);
}
// ─── personCount > 0 AND-semantics ───────────────────────────────────────
@Test
void findSummaries_with_one_personId_returns_only_linked_stories() {
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
Geschichte withFranz = published("Franz story", author);
withFranz.getPersons().add(franz);
geschichteRepository.save(withFranz);
Geschichte withAnna = published("Anna story", author);
withAnna.getPersons().add(anna);
geschichteRepository.save(withAnna);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
}
@Test
void findSummaries_with_two_personIds_uses_AND_semantics() {
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
Geschichte both = published("Both", author);
both.getPersons().add(franz);
both.getPersons().add(anna);
geschichteRepository.save(both);
Geschichte onlyFranz = published("Only Franz", author);
onlyFranz.getPersons().add(franz);
geschichteRepository.save(onlyFranz);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Both");
}
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
@Test
void findSummaries_with_documentId_returns_journey_containing_that_document() {
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
journeyItemRepository.save(JourneyItem.builder()
.geschichte(withDoc).document(doc).position(1).build());
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
}
@Test
void findSummaries_with_unknown_documentId_returns_empty() {
geschichteRepository.save(journey("Irgendeine Reise", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
assertThat(result).isEmpty();
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte published(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.PUBLISHED)
.author(writer)
.publishedAt(LocalDateTime.now())
.build();
}
private Geschichte draft(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.DRAFT)
.author(writer)
.build();
}
private Geschichte journey(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.build();
}
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
private List<UUID> sentinel() {
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
}
}

View File

@@ -0,0 +1,38 @@
package org.raddatz.familienarchiv.geschichte;
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.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeschichteQueryServiceTest {
@Mock
GeschichteRepository geschichteRepository;
@InjectMocks
GeschichteQueryService geschichteQueryService;
@Test
void existsById_returns_true_when_geschichte_exists() {
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(true);
assertThat(geschichteQueryService.existsById(id)).isTrue();
}
@Test
void existsById_returns_false_when_geschichte_does_not_exist() {
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(false);
assertThat(geschichteQueryService.existsById(id)).isFalse();
}
}

View File

@@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.geschichte.GeschichteView;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.beans.factory.annotation.Autowired;
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
S3Client s3Client;
@Autowired GeschichteService geschichteService;
@Autowired JourneyItemService journeyItemService;
@Autowired GeschichteRepository geschichteRepository;
@Autowired PersonRepository personRepository;
@Autowired AppUserRepository appUserRepository;
@@ -76,11 +80,11 @@ class GeschichteServiceIntegrationTest {
+ "<script>alert('xss')</script>");
dto.setPersonIds(List.of(franz.getId()));
Geschichte created = geschichteService.create(dto);
GeschichteView created = geschichteService.create(dto);
assertThat(created.getId()).isNotNull();
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.getBody())
assertThat(created.id()).isNotNull();
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.body())
.contains("<strong>jeden Sonntag</strong>")
.doesNotContain("<script>");
@@ -89,7 +93,7 @@ class GeschichteServiceIntegrationTest {
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId();
UUID draftId = created.id();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
.hasMessageContaining("not found");
@@ -97,16 +101,17 @@ class GeschichteServiceIntegrationTest {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
publishDto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.getPublishedAt()).isNotNull();
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.publishedAt()).isNotNull();
// Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
Geschichte fetched = geschichteService.getById(draftId);
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
// Delete as writer; join rows go with it
authenticateAs(writer, Permission.BLOG_WRITE);
@@ -137,17 +142,17 @@ class GeschichteServiceIntegrationTest {
// No filter → all three
assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(Geschichte::getId)
.extracting(GeschichteSummary::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// Single filter (Anna) → all three
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
.extracting(Geschichte::getId)
.extracting(GeschichteSummary::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
.extracting(Geschichte::getId)
.extracting(GeschichteSummary::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
geschichteService.create(dto);
authenticateAs(writer2, Permission.BLOG_WRITE);
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
assertThat(result).isEmpty();
}
@@ -185,7 +190,7 @@ class GeschichteServiceIntegrationTest {
dto.setBody("<p>body</p>");
dto.setPersonIds(personIds);
dto.setStatus(GeschichteStatus.PUBLISHED);
return geschichteService.create(dto).getId();
return geschichteService.create(dto).id();
}
private void authenticateAs(AppUser user, Permission... permissions) {

View File

@@ -7,26 +7,22 @@ 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.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -37,7 +33,10 @@ import java.util.stream.Collectors;
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.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -45,17 +44,13 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeschichteServiceTest {
@Mock
GeschichteRepository geschichteRepository;
@Mock
PersonService personService;
@Mock
DocumentService documentService;
@Mock
UserService userService;
@Mock GeschichteRepository geschichteRepository;
@Mock PersonService personService;
@Mock DocumentService documentService;
@Mock UserService userService;
@Mock JourneyItemService journeyItemService;
@InjectMocks
GeschichteService geschichteService;
@InjectMocks GeschichteService geschichteService;
AppUser writer;
AppUser reader;
@@ -96,7 +91,8 @@ class GeschichteServiceTest {
Geschichte result = geschichteService.getById(id);
assertThat(result).isSameAs(draft);
assertThat(result.getId()).isEqualTo(id);
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
}
@Test
@@ -108,7 +104,8 @@ class GeschichteServiceTest {
Geschichte result = geschichteService.getById(id);
assertThat(result).isSameAs(published);
assertThat(result.getId()).isEqualTo(id);
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
}
@Test
@@ -123,79 +120,175 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
// ─── getView ──────────────────────────────────────────────────────────────
@Test
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
Geschichte published = published(id);
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
GeschichteView view = geschichteService.getView(id);
assertThat(view.id()).isEqualTo(id);
assertThat(view.items()).containsExactly(item);
verify(journeyItemService).getItems(id);
}
@Test
void getView_throws_NOT_FOUND_when_id_unknown() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> geschichteService.getView(id))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
@Test
void toView_author_displayName_uses_firstName_lastName() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("author@test")
.firstName("Hans").lastName("Raddatz").build());
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
}
@Test
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("anon@test").build());
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
}
@Test
void toView_author_email_is_not_in_author_view() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("secret@test")
.firstName("Max").lastName("M").build());
GeschichteView result = geschichteService.toView(published, List.of());
// AuthorView exposes only id + displayName — no email field at all
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
assertThat(result.author().displayName()).doesNotContain("secret@test");
}
@Test
void toView_persons_are_mapped_to_PersonView() {
UUID id = UUID.randomUUID();
UUID personId = UUID.randomUUID();
Geschichte published = published(id);
published.setPersons(new HashSet<>(List.of(
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
)));
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.persons()).hasSize(1);
GeschichteView.PersonView pv = result.persons().iterator().next();
assertThat(pv.id()).isEqualTo(personId);
assertThat(pv.firstName()).isEqualTo("Franz");
assertThat(pv.lastName()).isEqualTo("Raddatz");
}
@Test
void toView_items_are_passed_through() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.items()).isEmpty();
}
// ─── list ─────────────────────────────────────────────────────────────────
@Test
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
geschichteService.list(null, List.of(), null, 50);
// Status pinning lives inside the Specification; we assert end-to-end behaviour
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
// through the spec-aware repository method.
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
GeschichteSummary s1 = mock(GeschichteSummary.class);
GeschichteSummary s2 = mock(GeschichteSummary.class);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(s1, s2));
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
assertThat(out).hasSize(2);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
authenticateAs(reader, Permission.READ_ALL);
UUID personId = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(personId), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
authenticateAs(reader, Permission.READ_ALL);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(a, b), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
void list_filters_by_documentId() {
void list_passes_documentId_to_repository_as_journey_item_filter() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
}
@Test
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(mock(GeschichteSummary.class)));
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
@@ -213,11 +306,11 @@ class GeschichteServiceTest {
dto.setTitle("My Story");
dto.setBody("<p>plain text</p>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
assertThat(saved.getAuthor()).isSameAs(writer);
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.publishedAt()).isNull();
assertThat(saved.author().id()).isEqualTo(writer.getId());
}
@Test
@@ -231,9 +324,9 @@ class GeschichteServiceTest {
dto.setTitle("XSS attempt");
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getBody())
assertThat(saved.body())
.contains("<p>safe</p>")
.doesNotContain("<script>")
.doesNotContain("onerror")
@@ -252,9 +345,9 @@ class GeschichteServiceTest {
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getBody())
assertThat(saved.body())
.contains("<h2>Heading</h2>")
.contains("<strong>bold</strong>")
.contains("<em>italic</em>")
@@ -277,28 +370,9 @@ class GeschichteServiceTest {
dto.setTitle("Linked");
dto.setPersonIds(List.of(personId));
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getPersons()).containsExactly(person);
}
@Test
void create_resolves_documentIds_via_DocumentService() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).build();
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Linked doc");
dto.setDocumentIds(List.of(docId));
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getDocuments()).containsExactly(doc);
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
}
@Test
@@ -315,6 +389,94 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}
@Test
void create_preserves_JOURNEY_type_from_dto() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Journey");
dto.setType(GeschichteType.JOURNEY);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
}
@Test
void create_defaults_to_STORY_when_type_is_null() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Story");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
}
@Test
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
// The journey intro is plain text: JourneyReader renders it via Svelte text
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
// would corrupt real content ("Müller & Söhne" → "Müller &amp; Söhne") and
// re-encode cumulatively on every editor round-trip.
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("Müller & Söhne, Temperatur < 0");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
}
@Test
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("Temperatur < 0 & Schnee");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
}
@Test
void update_still_sanitizes_STORY_body() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
// ─── update ──────────────────────────────────────────────────────────────
@Test
@@ -330,10 +492,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.getPublishedAt()).isNotNull();
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.publishedAt()).isNotNull();
}
@Test
@@ -349,10 +511,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.DRAFT);
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.publishedAt()).isNull();
}
@Test
@@ -366,9 +528,46 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
@Test
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.JOURNEY);
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
}
@Test
void update_accepts_dto_carrying_the_unchanged_type() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.STORY);
dto.setTitle("Unverändert getypt");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
assertThat(saved.title()).isEqualTo("Unverändert getypt");
}
@Test
@@ -426,7 +625,7 @@ class GeschichteServiceTest {
.body("<p>body</p>")
.status(GeschichteStatus.DRAFT)
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
@@ -438,7 +637,7 @@ class GeschichteServiceTest {
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now().minusHours(1))
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
}

View File

@@ -0,0 +1,153 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
* rollback-only and cascades into TransactionSystemException on teardown.
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JourneyItemConstraintsTest {
@MockitoBean
S3Client s3Client;
@Autowired JdbcTemplate jdbcTemplate;
@Autowired GeschichteRepository geschichteRepository;
@Autowired DocumentRepository documentRepository;
private UUID geschichteId;
private UUID documentId;
@BeforeEach
void seed() {
jdbcTemplate.execute("DELETE FROM journey_items");
Document doc = documentRepository.save(Document.builder()
.title("Constraints-Test-Doc")
.originalFilename("ct.pdf")
.status(DocumentStatus.UPLOADED)
.build());
documentId = doc.getId();
Geschichte g = geschichteRepository.save(Geschichte.builder()
.title("Constraints-Test-Journey")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
geschichteId = g.getId();
}
@Test
void unique_constraint_is_deferrable_initially_deferred() {
Boolean condeferrable = jdbcTemplate.queryForObject(
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
Boolean.class);
Boolean condeferred = jdbcTemplate.queryForObject(
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
Boolean.class);
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
}
@Test
void unique_index_rejects_duplicate_document_per_geschichte() {
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_index_allows_same_document_in_different_journeys() {
Geschichte other = geschichteRepository.save(Geschichte.builder()
.title("Andere Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), other.getId(), 10, documentId);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
assertThat(count).isEqualTo(2);
}
@Test
void unique_index_allows_multiple_note_only_items() {
// document_id IS NULL rows must not collide — the index is partial.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(2);
}
@Test
void note_length_check_rejects_2001_chars() {
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void position_check_rejects_nonpositive() {
UUID itemId = UUID.randomUUID();
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
itemId, geschichteId, 0, "test"))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_constraint_rejects_duplicate_position_per_geschichte() {
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
}

View File

@@ -0,0 +1,320 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class JourneyItemIntegrationTest {
@MockitoBean
S3Client s3Client;
@PersistenceContext
EntityManager em;
@Autowired GeschichteRepository geschichteRepository;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired JourneyItemService journeyItemService;
@Autowired DocumentRepository documentRepository;
@Autowired AppUserRepository appUserRepository;
Geschichte journey;
Document doc;
AppUser writer;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("journey-writer@test")
.password("hash")
.build());
doc = documentRepository.save(Document.builder()
.title("Testbrief")
.originalFilename("testbrief.pdf")
.status(DocumentStatus.UPLOADED)
.build());
journey = geschichteRepository.save(Geschichte.builder()
.title("Eine Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
em.flush();
em.clear();
}
@AfterEach
void clearSecurity() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))
.toList();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
// ─── @OrderBy ─────────────────────────────────────────────────────────────
@Test
void items_are_returned_in_position_order_regardless_of_insertion_order() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
// Distinct content per item — V74's partial unique index forbids the same
// document twice in one journey, and ordering doesn't depend on it.
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
managed.getItems().addAll(List.of(third, first, second));
geschichteRepository.save(managed);
em.flush();
em.clear();
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
assertThat(positions).containsExactly(1000, 2000, 3000);
}
// ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
@Test
void deleting_geschichte_cascade_deletes_all_journey_items() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
geschichteRepository.save(managed);
em.flush();
em.clear();
UUID geschichteId = journey.getId();
geschichteRepository.deleteById(geschichteId);
em.flush();
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
}
@Test
void removing_item_from_items_list_triggers_orphan_removal() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
geschichteRepository.save(reloaded);
em.flush();
assertThat(journeyItemRepository.findById(itemId)).isEmpty();
}
// ─── GeschichteType round-trip ────────────────────────────────────────────
@Test
void type_persists_as_JOURNEY_and_roundtrips() {
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
}
@Test
void type_defaults_to_STORY_for_new_geschichten() {
Geschichte story = geschichteRepository.save(Geschichte.builder()
.title("Erinnerung")
.status(GeschichteStatus.DRAFT)
.build());
em.flush();
em.clear();
Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
}
// ─── Note-only item (document_id IS NULL) ─────────────────────────────────
@Test
void note_only_item_persists_without_document() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem note = JourneyItem.builder()
.geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
managed.getItems().add(note);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID noteId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
assertThat(reloaded.getDocumentId()).isNull();
assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
}
// ─── Document-backed item exposes documentId ──────────────────────────────
@Test
void document_backed_item_exposes_document_uuid_via_getDocumentId() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder()
.geschichte(managed).position(1000).document(doc).build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
}
// ─── ON DELETE SET NULL ───────────────────────────────────────────────────
@Test
void deleting_document_sets_item_document_to_null_not_delete_item() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder()
.geschichte(managed).position(1000).document(doc).note("still here").build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
// Delete document — ON DELETE SET NULL fires at DB level
documentRepository.deleteById(doc.getId());
em.flush();
em.clear();
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("still here");
}
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
@Test
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem empty = JourneyItem.builder()
.geschichte(managed).position(1000).build();
assertThatThrownBy(() -> {
journeyItemRepository.save(empty);
journeyItemRepository.flush();
}).isInstanceOf(Exception.class);
}
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
@Test
void append_persists_item_at_position_10() {
// Arrange: authenticate as a user with BLOG_WRITE
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("First stop");
// Act
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
// Assert: item exists in DB at position 10
assertThat(view.position()).isEqualTo(10);
assertThat(view.note()).isEqualTo("First stop");
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
assertThat(persisted).hasSize(1);
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
}
@Test
void append_document_persists_and_rejects_duplicate() {
// Covers the document branch of append, including the duplicate guard —
// the derived exists query must resolve document.id, which the transient
// getDocumentId() getter on JourneyItem shadows for Spring Data.
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
assertThat(view.document()).isNotNull();
assertThat(view.document().id()).isEqualTo(doc.getId());
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
duplicate.setDocumentId(doc.getId());
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
.hasFieldOrPropertyWithValue("code",
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
}
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
@Test
void reorder_swaps_positions_atomically() {
// Arrange: append two items (pos 10, pos 20)
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
dto1.setNote("Item one");
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
dto2.setNote("Item two");
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
assertThat(item1View.position()).isEqualTo(10);
assertThat(item2View.position()).isEqualTo(20);
// Act: reorder with [item2, item1]
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
em.flush();
em.clear();
// Assert: item2 is now at position 10, item1 is at position 20
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
assertThat(persisted).hasSize(2);
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
}
}

View File

@@ -0,0 +1,745 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.junit.jupiter.api.BeforeEach;
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 org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
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.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class JourneyItemServiceTest {
@Mock JourneyItemRepository journeyItemRepository;
@Mock GeschichteQueryService geschichteQueryService;
@Mock DocumentService documentService;
@Mock AuditService auditService;
@Mock UserService userService;
@InjectMocks JourneyItemService journeyItemService;
UUID geschichteId = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID actorId = UUID.randomUUID();
@BeforeEach
void setupAuth() {
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("test@test.de", null,
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
}
// ─── toSummary — name composition ────────────────────────────────────────
@Test
void toSummary_uses_linked_person_firstName_lastName() {
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
Document doc = makeDoc(docId, sender, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
}
@Test
void toSummary_falls_back_to_senderText_when_no_person() {
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isEqualTo("Familie Müller");
}
@Test
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
Document doc = makeDoc(docId, null, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isNull();
}
@Test
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
Document doc = makeDoc(docId, null, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.receiverCount()).isEqualTo(0);
assertThat(summary.receiverName()).isNull();
}
@Test
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.receiverCount()).isEqualTo(2);
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
}
@Test
void toSummary_datePrecision_SEASON_roundtrips() {
Document doc = makeDoc(docId, null, List.of(), null, null);
doc.setMetaDatePrecision(DatePrecision.SEASON);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
}
@Test
void toSummary_datePrecision_APPROX_roundtrips() {
Document doc = makeDoc(docId, null, List.of(), null, null);
doc.setMetaDatePrecision(DatePrecision.APPROX);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
}
// ─── append ──────────────────────────────────────────────────────────────
@Test
void append_to_empty_journey_starts_at_10() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(10);
}
@Test
void append_after_reorder_continues_from_max_position() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(50);
}
@Test
void append_returns400_when_neither_documentId_nor_note() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("documentId or note");
}
@Test
void append_returns400_when_note_trims_to_empty_and_no_document() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote(" \n ");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(2001));
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@Test
void append_accepts_note_of_exactly_2000_chars() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(2000));
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
}
@Test
void append_returns409_on_non_JOURNEY_type() {
Geschichte story = Geschichte.builder()
.id(geschichteId)
.title("Story")
.type(GeschichteType.STORY)
.status(GeschichteStatus.DRAFT)
.build();
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH));
}
@Test
void append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY() {
// Arrange: mock geschichteQueryService.findById() to return a STORY-type Geschichte
UUID storyId = UUID.randomUUID();
Geschichte story = Geschichte.builder()
.id(storyId)
.type(GeschichteType.STORY)
.build();
when(geschichteQueryService.findById(storyId)).thenReturn(Optional.of(story));
// Act + Assert: calling append throws GESCHICHTE_TYPE_MISMATCH
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(storyId, dto))
.isInstanceOf(DomainException.class);
// Verify: document service was never touched — type guard fired first
verifyNoInteractions(documentService);
}
@Test
void append_returns404_when_documentId_does_not_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(documentService.findSummaryByIdInternal(docId))
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
}
@Test
void append_returns409_when_100_items_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
}
@Test
void append_returns409_when_document_already_in_journey() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void cap_is_COUNT_based_not_MAX_position_based() {
// 99 rows with MAX(position)=2000 should still accept the 100th append
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
}
@Test
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() {
// Two concurrent appends can both pass the exists() pre-check; the partial
// unique index then rejects the second INSERT at flush. The service must
// translate that into the same friendly 409 as the pre-check.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"duplicate key value violates unique constraint \"uq_journey_items_geschichte_document\""));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_audits_JOURNEY_ITEM_ADDED() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
journeyItemService.append(geschichteId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
}
// ─── updateNote ───────────────────────────────────────────────────────────
@Test
void updateNote_absent_leaves_note_unchanged() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
// note is null by default — absent from JSON, no-op
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isEqualTo("Original note");
verify(journeyItemRepository, never()).save(any());
}
@Test
void updateNote_null_clears_note_when_document_is_present() {
Geschichte journey = journey(geschichteId);
Document doc = makeDoc(docId, null, List.of(), null, null);
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.empty());
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void updateNote_string_sets_note() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, null);
item.setNote(null);
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("New note"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isEqualTo("New note");
}
@Test
void updateNote_null_returns400_when_item_has_no_document() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.empty());
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void updateNote_whitespace_only_including_newlines_stored_as_null() {
Geschichte journey = journey(geschichteId);
Document doc = makeDoc(docId, null, List.of(), null, null);
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("\n \n"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("x".repeat(2001)));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@Test
void updateNote_auditsNoteUpdate() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, null);
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("New note"));
journeyItemService.updateNote(geschichteId, itemId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
}
@Test
void patch_returns404_when_item_belongs_to_different_journey() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("text"));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
}
// ─── delete ───────────────────────────────────────────────────────────────
@Test
void delete_returns404_when_item_already_deleted() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
}
@Test
void delete_no_audit_when_item_not_found() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
.isInstanceOf(DomainException.class);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
@Test
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
journeyItemService.delete(geschichteId, itemId);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
}
// ─── reorder ─────────────────────────────────────────────────────────────
@Test
void reorder_unknownGeschichteId_throws404() {
UUID unknownId = UUID.randomUUID();
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
}
@Test
void reorder_returns400_when_itemIds_contain_duplicates() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id1)); // duplicate
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_itemId_belongs_to_different_journey() {
UUID foreignId = UUID.randomUUID();
UUID localId = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(foreignId));
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_ids_have_extra_items() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id2));
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void reorder_returns200_when_empty_on_empty_journey() {
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
assertThat(result).isEmpty();
}
@Test
void reorder_returns400_when_empty_on_nonempty_journey() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void reorder_returns_items_in_new_order_starting_at_10() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id2)); // want id1 first
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(2);
assertThat(views.get(0).id()).isEqualTo(id1);
assertThat(views.get(0).position()).isEqualTo(10);
assertThat(views.get(1).id()).isEqualTo(id2);
assertThat(views.get(1).position()).isEqualTo(20);
}
@Test
void reorder_identical_order_returns200() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1));
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(1);
assertThat(views.get(0).position()).isEqualTo(10);
}
@Test
void reorder_of_grandfathered_over_cap_journey_succeeds() {
Geschichte journey = journey(geschichteId);
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
List<UUID> ids = new java.util.ArrayList<>();
List<JourneyItem> items = new java.util.ArrayList<>();
for (int i = 1; i <= 130; i++) {
UUID id = UUID.randomUUID();
ids.add(id);
items.add(savedItem(id, journey, i * 10, null, "item " + i));
}
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(ids);
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(130);
}
@Test
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1));
journeyItemService.reorder(geschichteId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte journey(UUID id) {
return Geschichte.builder()
.id(id)
.title("Test Journey")
.type(GeschichteType.JOURNEY)
.status(GeschichteStatus.DRAFT)
.build();
}
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
return JourneyItem.builder()
.id(id)
.geschichte(g)
.position(position)
.document(null) // no document entity to avoid LAZY issues in unit tests
.note(note)
.build();
}
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
JourneyItem item = JourneyItem.builder()
.id(id)
.geschichte(g)
.position(position)
.document(doc)
.note(note)
.build();
return item;
}
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
Document doc = Document.builder()
.id(id)
.title("Test Doc")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.senderText(senderText)
.receiverText(receiverText)
.sender(sender)
.build();
doc.setReceivers(new HashSet<>(receivers));
return doc;
}
}

View File

@@ -121,37 +121,60 @@ class PersonRepositoryTest {
.containsExactly("Anna", "Clara");
}
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
// ─── findByAlias (exact) / findAllByAliasIgnoreCase (case-folding siblings) ───
@Test
void findByAliasIgnoreCase_returnsMatchingPerson() {
void findByAlias_returnsExactCaseMatchOnly() {
personRepository.save(Person.builder()
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Karl");
assertThat(personRepository.findByAlias("Opa Karl")).isPresent();
assertThat(personRepository.findByAlias("opa karl")).isEmpty(); // exact-case: a folded form does NOT match
}
@Test
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
assertThat(found).isEmpty();
void findAllByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
assertThat(personRepository.findAllByAliasIgnoreCase("nobody")).isEmpty();
}
// ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
@Test
void findAllByAliasIgnoreCase_foldsUmlautCase_inRealPostgres() {
// Proves Postgres LOWER() folds ü the same way for both rows — a plain-ASCII probe would
// stay green even if umlaut folding regressed. Both case-colliding aliases must match.
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
assertThat(personRepository.findAllByAliasIgnoreCase("MÜLLER")).hasSize(2);
}
// ─── findByFirstNameAndLastName (exact) / findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase ───
@Test
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
void findByFirstNameAndLastName_returnsExactCaseMatchOnly() {
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
"maria", "raddatz");
assertThat(personRepository.findByFirstNameAndLastName("Maria", "Raddatz")).isPresent();
assertThat(personRepository.findByFirstNameAndLastName("maria", "raddatz")).isEmpty(); // exact-case only
}
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Maria");
@Test
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_foldsUmlautCase_inRealPostgres() {
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("HANS", "MÜLLER"))
.hasSize(2);
}
@Test
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch() {
// Fail-closed: a last-name-only filename (null first name) must NOT widen to first_name IS
// NULL and pull in the institution/last-name-only row as a "sender". Proven on real
// Postgres because a mocked unit test cannot catch the IS NULL vs `= NULL` semantics.
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
.isEmpty();
}
// ─── findCorrespondents ───────────────────────────────────────────────────
@@ -405,6 +428,67 @@ class PersonRepositoryTest {
assertThat(results).hasSize(1);
}
@Test
void searchByName_findsByAliasFirstName() {
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
List<Person> results = personRepository.searchByName("Wilhelmina");
assertThat(results).hasSize(1);
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
}
@Test
void searchByName_ordersByLastNameThenFirstName() {
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
List<Person> results = personRepository.searchByName("Cram");
assertThat(results).extracting(Person::getFirstName)
.containsExactly("Anna", "Bernd", "Clara");
}
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
// fetch query actually finds an alias-only match and feeds it into classification. These walk
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
@Test
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
PersonService personService = new PersonService(personRepository, aliasRepository);
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
entityManager.flush();
entityManager.clear();
NameMatches matches = personService.resolveByName("Clara Cram");
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
}
@Test
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
PersonService personService = new PersonService(personRepository, aliasRepository);
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
entityManager.flush();
entityManager.clear();
NameMatches matches = personService.resolveByName("Wilhelmina");
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
}
// ─── searchWithDocumentCount with aliases ────────────────────────────────
@Test

View File

@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonType;
@@ -16,10 +17,13 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import org.springframework.mock.web.MockMultipartFile;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,6 +37,7 @@ class PersonServiceIntegrationTest {
@Autowired PersonService personService;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@Autowired DocumentService documentService;
@PersistenceContext EntityManager entityManager;
@@ -75,6 +80,93 @@ class PersonServiceIntegrationTest {
assertThat(result.getLastName()).isEqualTo("Cram");
}
// ─── #731: case-colliding alias resolution against real Postgres ───────────
// The umlaut pair is mandatory — only the real DB proves Postgres LOWER() folds ü; a
// plain-ASCII test would stay green while umlaut aliases regressed.
@Test
void findOrCreateByAlias_resolvesUmlautAliasCollision_toLowestId_withoutThrow() {
Person muller = personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
Person mullerLower = personRepository.save(Person.builder().lastName("müller").alias("müller").build());
UUID expected = muller.getId().compareTo(mullerLower.getId()) <= 0 ? muller.getId() : mullerLower.getId();
// No exact-case "MÜLLER" row → falls through to the case-insensitive branch with two
// candidates and must pick the lowest id, never throwing NonUniqueResultException.
Person resolved = personService.findOrCreateByAlias("MÜLLER");
assertThat(resolved.getId()).isEqualTo(expected);
}
@Test
void findOrCreateByAlias_umlautAliasCollision_isDeterministicAcrossCalls() {
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
Person first = personService.findOrCreateByAlias("MÜLLER");
Person second = personService.findOrCreateByAlias("MÜLLER");
assertThat(second.getId()).isEqualTo(first.getId());
}
// ─── #731: filename-based sender resolution against real Postgres ──────────
@Test
void storeDocument_resolvesSender_whenFilenameNameIsUnique() throws Exception {
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
Document doc = uploadNamed("1965-03-12_Müller_Hans.pdf").document();
assertThat(doc.getSender()).isNotNull();
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
}
@Test
void storeDocument_resolvesSender_onSingleCaseInsensitiveMatch() throws Exception {
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
// Filename folds to "hans müller"; the only stored person is "Hans Müller".
Document doc = uploadNamed("1965-03-12_müller_hans.pdf").document();
assertThat(doc.getSender()).isNotNull();
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
}
@Test
void storeDocument_leavesSenderUnset_whenFilenameNameIsAmbiguous() throws Exception {
// Two persons collide case-insensitively; the filename casing ("HANS"/"MÜLLER") matches
// neither exactly → no exact-case winner → bail to null (never an arbitrary guess), no 500.
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
Document doc = uploadNamed("1965-03-12_MÜLLER_HANS.pdf").document();
assertThat(doc.getSender()).isNull();
}
@Test
void storeDocument_leavesSenderUnset_whenFilenameHasNoFirstName() throws Exception {
// A last-name-only filename never resolves to a sender (the parser yields no parsed name).
personRepository.save(Person.builder().lastName("Müller").build());
Document doc = uploadNamed("1965-03-12_Müller.pdf").document();
assertThat(doc.getSender()).isNull();
}
@Test
void findByName_nullFirstName_resolvesToEmpty_inRealPostgres() {
// Fail-closed against the real DB: a null first name must NOT widen to first_name IS NULL
// and pick up the last-name-only row.
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
assertThat(personService.findByName(null, "Müller")).isEmpty();
}
private DocumentService.StoreResult uploadNamed(String filename) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1, 2, 3});
return documentService.storeDocument(file, null);
}
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
@Test

View File

@@ -375,14 +375,57 @@ class PersonServiceTest {
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
String alias = "Walter de Gruyter";
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
void findOrCreateByAlias_returnsExactCaseMatch_overCaseInsensitiveSibling() {
String alias = "müller";
Person exact = Person.builder().id(UUID.randomUUID()).alias("müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(existing);
assertThat(result).isEqualTo(exact);
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
verify(personRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_returnsExactCaseMatch_evenWhenMultipleSiblingsCollide() {
String alias = "Müller";
Person exact = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(exact);
// exact-case short-circuits — the case-insensitive siblings are never consulted.
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
}
@Test
void findOrCreateByAlias_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
String alias = "müller";
Person only = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(only));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(only);
verify(personRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
String alias = "müller";
Person lower = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).alias("Müller").build();
Person higher = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).alias("müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(higher, lower)); // unordered
Person first = personService.findOrCreateByAlias(alias);
Person second = personService.findOrCreateByAlias(alias);
assertThat(first.getId()).isEqualTo(lower.getId()); // lowest id wins
assertThat(second.getId()).isEqualTo(first.getId()); // same result every call — never throws
verify(personRepository, never()).save(any());
}
@@ -390,7 +433,8 @@ class PersonServiceTest {
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
String alias = "Clara Cram";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenReturn(saved);
Person result = personService.findOrCreateByAlias(alias);
@@ -403,7 +447,8 @@ class PersonServiceTest {
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
String alias = "Clara Cram geb. de Gruyter";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenReturn(saved);
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -425,7 +470,8 @@ class PersonServiceTest {
@Test
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
String alias = "Arthur Collignon GmbH";
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenAnswer(inv -> {
Person p = inv.getArgument(0);
p.setId(UUID.randomUUID());
@@ -442,7 +488,8 @@ class PersonServiceTest {
@Test
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
String alias = "Geschwister de Gruyter";
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenAnswer(inv -> {
Person p = inv.getArgument(0);
p.setId(UUID.randomUUID());
@@ -460,7 +507,8 @@ class PersonServiceTest {
void findOrCreateByAlias_noAlias_whenNoGeb() {
String alias = "Clara Cram";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.save(any())).thenReturn(saved);
personService.findOrCreateByAlias(alias);
@@ -472,11 +520,54 @@ class PersonServiceTest {
void findOrCreateByAlias_trimsInput() {
String alias = " Clara Cram ";
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
when(personRepository.findByAlias("Clara Cram")).thenReturn(Optional.of(saved));
personService.findOrCreateByAlias(alias);
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
verify(personRepository).findByAlias("Clara Cram");
}
// ─── findByName (filename-based sender resolution) ────────────────────────
@Test
void findByName_returnsExactCaseMatch_overCaseInsensitiveSibling() {
Person exact = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personRepository.findByFirstNameAndLastName("Hans", "Müller")).thenReturn(Optional.of(exact));
assertThat(personService.findByName("Hans", "Müller")).contains(exact);
verify(personRepository, never()).findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(any(), any());
}
@Test
void findByName_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
Person only = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
.thenReturn(List.of(only));
assertThat(personService.findByName("hans", "müller")).contains(only);
}
@Test
void findByName_bailsToEmpty_whenTwoOrMoreCaseInsensitiveMatches() {
Person a = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
Person b = Person.builder().id(UUID.randomUUID()).firstName("hans").lastName("müller").build();
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
.thenReturn(List.of(a, b));
// Ambiguous sender → unset, never an arbitrary guess (provenance correctness over a
// confidently-wrong pre-fill). This is the deliberate divergence from the alias path.
assertThat(personService.findByName("hans", "müller")).isEmpty();
}
@Test
void findByName_returnsEmpty_whenFirstNameNullFoldsToNoMatch() {
when(personRepository.findByFirstNameAndLastName(null, "Müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
.thenReturn(List.of());
assertThat(personService.findByName(null, "Müller")).isEmpty();
}
// ─── updatePerson (notes) ────────────────────────────────────────────────
@@ -807,4 +898,165 @@ class PersonServiceTest {
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(403);
}
@Test
void findByDisplayNameContaining_delegatesToSearchByName() {
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(personRepository.searchByName("Walter")).thenReturn(List.of(walter));
List<Person> result = personService.findByDisplayNameContaining("Walter");
assertThat(result).containsExactly(walter);
verify(personRepository).searchByName("Walter");
}
// ─── tokenize (name-match contract) ───────────────────────────────────────
@Test
void tokenize_hyphenatedName_splitsOnHyphen() {
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
}
@Test
void tokenize_apostropheName_splitsOnApostrophe() {
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
}
@Test
void tokenize_umlautName_lowercasesToSingleToken() {
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
}
@Test
void tokenize_doubleSpace_dropsEmptyTokens() {
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
}
@Test
void tokenize_allWhitespace_returnsEmpty() {
assertThat(PersonService.tokenize(" ")).isEmpty();
}
@Test
void tokenize_null_returnsEmpty() {
assertThat(PersonService.tokenize(null)).isEmpty();
}
// ─── resolveByName (direct / partial classification) ──────────────────────
@Test
void resolveByName_singleDirectMatch_classifiesAsDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_maidenAliasToken_classifiesAsDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
.type(PersonNameAliasType.MAIDEN_NAME).build()))
.build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).build()))
.build();
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Wilhelmina");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_middleName_stillDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_reorderedTokens_stillDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Cram Clara");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_cramVsCramer_classifiesAsPartial() {
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.partial()).containsExactly(cramer);
}
@Test
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
NameMatches result = personService.resolveByName(" - ");
assertThat(result.direct()).isEmpty();
verify(personRepository, never()).searchByName(any());
}
@Test
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
List<Person> pool = new java.util.ArrayList<>();
for (int i = 0; i < 10; i++) {
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
}
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
pool.add(direct);
when(personRepository.searchByName("clara")).thenReturn(pool);
when(personRepository.searchByName("cram")).thenReturn(pool);
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(direct);
}
@Test
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
personService.resolveByName("a b c d e f g h i j");
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
}
@Test
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
// candidate is classified once, not twice.
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).hasSize(1);
assertThat(result.partial()).isEmpty();
}
}

View File

@@ -666,4 +666,17 @@ class TagServiceTest {
// verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
verify(tagRepository, atLeastOnce()).findAllById(any());
}
// ─── findByNameContaining ─────────────────────────────────────────────────
@Test
void findByNameContaining_delegatesToRepository() {
Tag krieg = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
when(tagRepository.findByNameContainingIgnoreCase("krieg")).thenReturn(List.of(krieg));
List<Tag> result = tagService.findByNameContaining("krieg");
assertThat(result).containsExactly(krieg);
verify(tagRepository).findByNameContainingIgnoreCase("krieg");
}
}

View File

@@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
**`geschichte`** — family stories and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (ordered stops in a JOURNEY-type Geschichte). Two subtypes: `STORY` (prose) and `JOURNEY` (curated document sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL).
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`. |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter``PersonRegisterImporter``PersonTreeImporter``DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |

View File

@@ -52,11 +52,12 @@ The OCR service requires significant RAM for model loading. The dev compose sets
| Production target | RAM | Recommended OCR limit | Notes |
|---|---|---|---|
| Hetzner CX42 | 16 GB | 12 GB | Recommended for OCR-enabled production |
| Hetzner CX32 | 8 GB | 6 GB | Accept reduced batch sizes and slower throughput |
| Hetzner CX22 | 4 GB | — | Disable the OCR service (`profiles: [ocr]`); run OCR on demand only |
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Default `mem_limit: 12g` works comfortably |
| ≥ 16 GB RAM | 16+ GB | 12 GB | Default works |
| 8 GB RAM | 8 GB | 6 GB | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
| 4 GB RAM | 4 GB | — | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
A CX32 cannot honour the default `mem_limit: 12g` — set the `OCR_MEM_LIMIT=6g` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow) before deploying on a CX32. The prod compose interpolates this var with a 12g default.
On servers with less than 16 GB RAM the default `mem_limit: 12g` cannot be honoured — set the `OCR_MEM_LIMIT` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow). The prod compose interpolates this var with a 12g default.
### Dev vs production differences
@@ -140,7 +141,7 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `ALLOWED_PDF_HOSTS` | SSRF protection — comma-separated list of allowed PDF source hosts. **Do not widen to `*`** | `minio,localhost,127.0.0.1` | YES | — |
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on servers with 8 GB RAM; leave unset (12g default) on servers with ≥ 16 GB RAM | `12g` (prod compose default) | — | — |
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
@@ -264,6 +265,7 @@ git.raddatz.cloud A <server IP>
### 3.4 First deploy
```bash
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
# Expected: docker compose up -d --wait succeeds for archiv-staging, then

View File

@@ -149,7 +149,20 @@ _See also [Chronik](#chronik-internal)._
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a single stop in a *Lesereise* (`Geschichte` with `type=JOURNEY`). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per journey). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`).
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<JourneyItemView>` loaded via a separate query rather than a lazy collection.
**JourneyItemView** (`JourneyItemView`) `[internal]` — lean view record for a single `JourneyItem` surface, containing `id`, `position`, an optional `DocumentSummary`, and an optional `note`.
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
**Interlude / Zwischentext** `[user-facing]` — an editorial paragraph inserted between document items in a *Lesereise*. An interlude is a `JourneyItem` with `document_id IS NULL` and a non-empty `note`; its content is a plain-text string stored in the `note` column (not `body` or `text`). Visually distinguished by `--color-interlude-bg/border/label` CSS tokens and a `ZWISCHENTEXT` label. Interludes cannot have their note removed (removing the interlude deletes the entire item).
_Not to be confused with a document item's optional note_ — a document item's note is curator commentary attached to a linked letter; an interlude is standalone editorial prose with no backing document.
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
@@ -165,6 +178,8 @@ _See also [Chronik](#chronik-internal)._
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer").
---
## Infrastructure Terms

View File

@@ -35,7 +35,7 @@ Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (alrea
**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.
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on memory-constrained hosts. A busy backfill alongside OCR can approach the 3 GB heap on an 8 GB server — acceptable but not comfortable. The current production server (64 GB) has ample headroom. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
### Operational caveats (intentional)

View File

@@ -62,7 +62,7 @@ The `/tmp` tmpfs remains at 512 MB and continues to serve training-ZIP extractio
## Alternatives considered
**Approach B — Enlarge `/tmp` to 4 GB**
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on CX32 hosts with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on servers with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
**Approach C — Both TMPDIR redirect and enlarged /tmp**
Belt-and-suspenders: Approach A + 1 GB tmpfs. Discarded in favour of the cleaner Approach A. The defence-in-depth benefit does not outweigh the extra compose churn; the 512 MB cap on `/tmp` is intentional.

View File

@@ -1,4 +1,4 @@
# ADR-032 — Tag-name resolution tolerates case-collisions: exact-case first, then a deterministic lowest-id fallback, and never a `unique(lower(name))` constraint
# ADR-033 — Tag-name resolution tolerates case-collisions: exact-case first, then a deterministic lowest-id fallback, and never a `unique(lower(name))` constraint
**Date:** 2026-06-06
**Status:** Accepted
@@ -82,15 +82,58 @@ added later.
`IncorrectResultSizeDataAccessException`, and `GlobalExceptionHandler`'s generic handler maps
any stray one to `INTERNAL_ERROR` with no Hibernate/SQL leak — so no dedicated handler was
added.
- **The sibling Person path is unfixed but tracked.** `PersonService.findOrCreateByAlias`
(`findByAliasIgnoreCase`) and `findByFirstNameIgnoreCaseAndLastNameIgnoreCase` carry the same
latent `Optional`-non-unique throw on user-influenced names; deferred to #731 rather than
widened into this fix.
- **The sibling Person path is fixed the same way — see the Person extension below (#731).**
- Postgres `LOWER()` folding of umlauts (`ü`/`ä`) is the actual correctness hinge of the
fallback and cannot be proven by a mocked repo, so it is pinned by a Testcontainers
`postgres:16-alpine` test on a `Glückwünsche`/`glückwünsche` pair; a plain-ASCII test would
stay green while the bug reappeared for umlaut tags.
## Person extension (#731)
The Person domain carried the same latent throw on **two** user-influenced lookup surfaces, and
is fixed with the same exact-case-first, non-throwing pattern — but with a deliberately
**different fallback per surface**, because the two paths have different consequences.
- **Alias path — `PersonService.findOrCreateByAlias` — deterministic lowest-id (mirrors tag).**
`findByAliasIgnoreCase` (`Optional`) is replaced by `findByAlias` (exact) → `findAllByAliasIgnoreCase`
(plural, lowest id) → the existing create-when-absent branch (INSTITUTION/GROUP and the
maiden-name alias are preserved verbatim). There is no human in the importer loop and the path
creates-on-absent anyway, so a deterministic guess is the right behaviour — exactly like tags.
- **Name/sender path — `PersonService.findByName` — bail to null on ambiguity (the new wrinkle).**
Used only by `DocumentService.storeDocument` to resolve the upload **sender** from the parsed
filename. `findByFirstNameIgnoreCaseAndLastNameIgnoreCase` (`Optional`) is replaced by
`findByFirstNameAndLastName` (exact) → `findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase`
(plural). Resolution returns the exact-case match, else the single case-insensitive match, else
— on **two or more** matches — **empty**. The sender is left unset rather than guessing.
**Why this diverges from the alias (and tag) decision:** the archive's value is correct
provenance. A confidently-wrong pre-filled `Hans Müller` is worse than an empty field, because a
senior reviewer will not re-check a value that is already filled in, whereas an empty sender
routes the document into the "needs completion" state (`metadataComplete=false`) for a human to
assign. The load-bearing comment at `findByName` records this so a future "consistency cleanup"
does not reintroduce the confidently-wrong-sender bug by switching it to lowest-id.
- **Fail-closed on a null first name.** A parsed filename can lack a first name. The two new name
methods use explicit HQL equality (`= :firstName`) rather than a derived
`…IgnoreCase` query, because Spring Data folds a null derived-query argument to `first_name IS
NULL` — which would silently widen the match and pull a last-name-only / institution row in as a
"sender" (a quiet provenance-integrity defect). With HQL equality a null binds as `= NULL`,
which never matches, so a null first name resolves to **no sender**. This is pinned by a
real-Postgres repository test.
- **Scope — "ambiguous" is case-insensitive only.** Both exact-case lookups (`findByAlias`,
`findByFirstNameAndLastName`) return `Optional`, so two **byte-identical same-case** rows would
still throw `NonUniqueResultException`. That is a true data anomaly, deliberately out of scope
(it is not a case-collision), and it surfaces as the opaque `INTERNAL_ERROR` — never a silently
wrong row — so it is no worse than any other unexpected error and needs no extra handling here.
- **Same stance as tags otherwise:** no `unique(lower(alias))` / `unique(lower(name))` constraint
(collisions are valid human labels; `source_ref` is the stable identity per ADR-025), no
merge/dedupe, code-only and reversible, and no shared `resolveExactThenCi(...)` helper — the
two Person paths have different fallbacks, so the exact→CI→fallback logic is inlined at each
with its load-bearing comment (KISS).
## Alternatives considered
- **A `unique(lower(name))` index** — rejected: the collisions are valid canonical nodes, and

View File

@@ -0,0 +1,53 @@
# ADR-034 — Remove NL/smart-search (supersedes ADR-028 ×2, ADR-034-ollama, ADR-035)
**Date:** 2026-06-07
**Status:** Accepted
**Issue:** #772
**Supersedes:** ADR-028 (nl-search-ollama), ADR-028 (ollama-docker-compose-service), ADR-034 (ollama-production-deployment-and-keep-alive), ADR-035 (rule-based-nlp-service)
---
## Context
The natural-language search feature ("KI-Suche" / smart search) allowed users to enter
free-form queries like *"Was hat Walter an Emma im Krieg geschrieben?"* and have them
interpreted by an LLM into structured filters (persons, tags, date range, keywords).
The feature went through two major iterations:
1. **Ollama integration** (ADR-028): an `ollama` Docker service running a local LLM
(llama3.2/gemma3) parsed queries via a JSON-mode prompt.
2. **Rule-based NLP service** (ADR-035): after Ollama proved too slow and unreliable on
CPU-only hardware, a Python FastAPI microservice (`nlp-service`, port 8001) replaced
it with deterministic regex + spaCy parsing plus a lightweight LLM call.
Both approaches shared the same fundamental problem: inference on the production server
(Hetzner Serverbörse, no GPU, 64 GB RAM, i7-6700) was too slow to be useful, with
typical query latencies of 1030 seconds. Users got better and faster results from
the existing keyword search with date/person/tag filters.
## Decision
**Remove the NL search feature entirely.** The Python `nlp-service` microservice, the
Spring Boot `search/` package (`NlSearchController`, `NlQueryParserService`,
`RestClientNlpClient`, `NlSearchRateLimiter`, and all supporting classes), the frontend
NL search components (`SmartModeToggle`, `SmartSearchStatus`, `InterpretationChipRow`,
`DisambiguationPicker`), the related Docker Compose services, Prometheus scrape job,
Grafana dashboard, and all i18n keys are removed.
The existing structured search (FTS keyword + person/tag/date/directional filters) is
sufficient for the archive's current audience and search workload.
## Consequences
- **Capability removed:** users can no longer enter free-form natural-language queries.
They must use the structured filter bar (keyword text box + person/tag/date/directional
dropdowns). For documents where these filters are sufficient, there is no regression.
- **Operational simplification:** the Docker Compose stack loses two services
(`nlp-service` and previously `ollama`/`ollama-model-init`). Memory budget on the
production host is freed. No external model weights need to be kept warm.
- **Future reinstatement:** if a GPU-capable host becomes available, re-implementing
server-side LLM inference would be straightforward given the clean separation of the
`NlSearchController` entry point. However, this ADR deliberately avoids leaving dead
infrastructure or stub code in place — start clean if and when that becomes viable.
- **No data or schema change:** only query/endpoint code and Docker services are removed.
The `documents`, `persons`, and `tags` tables and their FTS indexes are untouched.

View File

@@ -0,0 +1,43 @@
# ADR-035 — `Optional<String>` for three-way PATCH semantics
**Status:** Accepted
**Date:** 2026-06-08
**Issue:** #751 (JourneyItem CRUD API)
## Context
The `PATCH /api/geschichten/{id}/items/{itemId}` endpoint must distinguish three cases for the `note` field:
| JSON body | Intended meaning |
|-------------------|-----------------------|
| `{"note": "text"}`| Set note to "text" |
| `{"note": null}` | Clear the note |
| `{}` (absent) | Leave note unchanged |
The standard library for this on Jackson 2.x is `jackson-databind-nullable` (`JsonNullable<T>` from `org.openapitools`). However, that library targets `com.fasterxml.jackson.*` (Jackson 2.x) and is incompatible with Spring Boot 4.0 / Spring Framework 7, which uses `tools.jackson.*` (Jackson 3.x). The module fails to register and throws at startup.
## Decision
Use `Optional<String>` with Java's default field initializer (`= null`) to encode the three states:
```java
@Data
public class JourneyItemUpdateDTO {
private Optional<String> note = null; // Java default — absent = no-op
}
```
| Java value | JSON wire | Semantics |
|--------------------|-------------------|---------------|
| `null` (default) | field absent | no-op |
| `Optional.empty()` | `{"note": null}` | clear |
| `Optional.of("x")` | `{"note": "x"}` | set |
Jackson 3.x natively maps a JSON `null` to `Optional.empty()` and leaves absent fields at their Java default. No custom module is needed.
## Consequences
- No external dependency for PATCH semantics — simpler pom.xml.
- The DTO field type is `Optional<String>`, not `String` — service code must null-check the field first (`if (noteField == null) return;`) and then call `.orElse(null)` to unwrap.
- This pattern applies to any future PATCH DTO that needs three-way semantics on a nullable field.
- `jackson-databind-nullable` is removed from `pom.xml`; `JacksonConfig.java` is kept as a placeholder for future custom modules.

View File

@@ -0,0 +1,65 @@
# ADR-036 — Geschichte responses are views assembled in-transaction, never entities
**Status:** Accepted
**Date:** 2026-06-10
**Issue:** #753 (JourneyEditor frontend), PR #792 review
## Context
The project convention (CLAUDE.md §DTOs) has been: *"Response types are the model
entities themselves (no response DTOs)."* That convention assumed entities whose
associations are either eager or initialized by the time Jackson serializes.
The lazy-fetch migration (ADR-022, `open-in-view: false`) broke that assumption:
Jackson serializes **after** the service transaction has closed, so any lazy
collection on a returned entity is a dead proxy. `Geschichte.items` (added with the
Lesereisen data model, #750) made this concrete: every `PATCH /api/geschichten/{id}`
(save draft, publish) failed with HTTP 500
`LazyInitializationException: Geschichte.items … (no session)`.
Per-endpoint force-initialization (`g.getItems().size()` inside the transaction)
worked for `getById()` but is a footgun: every new write method must remember the
trick, the entity carries a warning comment nobody reads, and the raw entity also
leaks the `author` `AppUser` graph (email, password hash, groups).
## Decision
In the **geschichte domain**, controllers never return entities. Every response is a
purpose-built read model assembled **inside** the service transaction:
- `GET /api/geschichten``GeschichteSummary` (projection; never carries items;
author exposes names only — never email)
- `GET /api/geschichten/{id}``GeschichteView` (with `AuthorView`, `PersonView`,
`JourneyItemView` items)
- `POST /api/geschichten`, `PATCH /api/geschichten/{id}``GeschichteView`
- JourneyItem endpoints → `JourneyItemView`
The invariant: **entities never cross the controller boundary in this domain.**
A view is constructed while the Hibernate session is open, so serialization can
never touch a lazy proxy, and the response shape is an explicit, security-reviewed
contract.
## Alternatives rejected
- **`@Transactional` on read/write methods + force-init (`getItems().size()`)** —
fixes one endpoint at a time, silently regresses when the next write method is
added, and still serializes the raw `AppUser` author graph.
- **`open-in-view: true`** — re-opens the session during rendering; hides N+1
queries and couples the HTTP layer to Hibernate session lifetime. Rejected
already by ADR-022.
- **Jackson `@JsonIgnore` on lazy fields** — loses the data the client needs
(items ARE the journey) instead of loading it deliberately.
## Consequences
- CLAUDE.md §DTOs names the geschichte domain as the exception to the
entities-as-responses convention. Other domains (document, person, tag) still
return entities; they predate ADR-022's lazy collections on their hot paths and
migrate opportunistically when they grow lazy collections of their own.
- `npm run generate:api` must run after any view change — the generated
`Geschichte` schema no longer exists; frontend consumers use
`GeschichteView`/`GeschichteSummary`.
- New geschichte endpoints must add a view (or extend an existing one), not return
the entity. The regression guards are `GeschichteHttpTest`
(`update_returns_200_and_serializes_items_open_in_view_false`) and
`GeschichteListProjectionTest`.

View File

@@ -9,10 +9,12 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.")
System_Ext(ollama, "Ollama (self-hosted)", "Local LLM inference server (qwen2.5:7b). Parses natural-language search queries into structured filters. Runs in the same Docker Compose stack.")
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
Rel(familienarchiv, ollama, "NL query parsing for natural-language search", "HTTP / REST (internal)")
@enduml

View File

@@ -18,7 +18,7 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
}
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend management port 8081 (/actuator/prometheus), node-exporter, and cAdvisor. Retention: 30 days.")
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
@@ -45,6 +45,7 @@ Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100")
Rel(grafana, tempo, "Queries traces", "HTTP 3200")

View File

@@ -16,8 +16,10 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories (STORY) and reading journeys (JOURNEY). Returns GeschichteSummary projections for list; full Geschichte with JourneyItems for detail. Requires BLOG_WRITE permission for write operations.")
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Supports two subtypes: STORY (prose) and JOURNEY (ordered JourneyItem sequence). Sanitizes HTML body with an allowlist policy.")
Component(geschQuerySvc, "GeschichteQueryService", "Spring Service", "Read-only facade over GeschichteRepository. Exposes existsById() and findById() to prevent JourneyItemService from crossing domain boundaries.")
Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Enforces JOURNEY-type guard on append.")
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
}
@@ -38,6 +40,10 @@ Rel(notifCtrl, notifSvc, "Delegates to")
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
Rel(geschCtrl, geschSvc, "Delegates to")
Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD")
Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type")
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
Rel(auditSvc, db, "Writes audit_log", "JDBC")
Rel(auditQuery, db, "Reads audit_log", "JDBC")
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")

View File

@@ -10,6 +10,11 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
Component(searchFilterBar, "SearchFilterBar.svelte", "Svelte Component", "Search/filter card on /documents. Hosts the keyword input, sort, advanced filters, and the smart-mode toggle. In smart mode submits the NL query on Enter via onSmartSearch instead of the live keyword search.")
Component(smartToggle, "search/SmartModeToggle.svelte", "Svelte Component", "Toggle pill (KI/Text) inside the search input. aria-pressed; switches between keyword and NL (smart) search modes.")
Component(chipRow, "search/InterpretationChipRow.svelte", "Svelte Component", "Renders NL interpretation chips (Absender / directional / Zeitraum / Stichwort). Removing a chip emits onRemoveChip; the page re-runs a keyword GET with the remaining params.")
Component(smartStatus, "search/SmartSearchStatus.svelte", "Svelte Component", "Full-area panels for NL search: loading (role=status), 503 SMART_SEARCH_UNAVAILABLE (with keyword fallback), 429 SMART_SEARCH_RATE_LIMITED.")
Component(disambig, "search/DisambiguationPicker.svelte", "Svelte Component", "Accessible single-select disclosure for ambiguous person names; selecting a candidate re-runs the search via GET.")
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
@@ -25,6 +30,12 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
Rel(homePage, timelineFilter, "Mounts above the result list")
Rel(homePage, searchFilterBar, "Mounts the search/filter card")
Rel(searchFilterBar, smartToggle, "Embeds the smart-mode toggle in the input")
Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")
Rel(homePage, smartStatus, "Renders loading / 503 / 429 panels")
Rel(homePage, chipRow, "Renders interpretation chips; handles chip removal")
Rel(homePage, disambig, "Renders the picker when names are ambiguous")
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")

View File

@@ -11,8 +11,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
@@ -24,8 +24,8 @@ Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearc
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")

View File

@@ -1,6 +1,6 @@
@startuml db-orm
' Schema source: Flyway V1V69 (excl. V37, V43 — intentionally removed)
' Schema as of: V69 (2026-05-27)
' Schema source: Flyway V1V72 (excl. V37, V43 — intentionally removed)
' Schema as of: V72 (2026-06-08)
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
hide circle
@@ -359,6 +359,7 @@ package "Supporting" {
title : VARCHAR(255) NOT NULL
body : TEXT
status : VARCHAR(32) NOT NULL
type : VARCHAR(32) NOT NULL
author_id : UUID <<FK>>
created_at : TIMESTAMP NOT NULL
updated_at : TIMESTAMP NOT NULL
@@ -370,9 +371,16 @@ package "Supporting" {
person_id : UUID <<FK>>
}
entity geschichten_documents {
entity journey_items {
id : UUID <<PK>>
--
geschichte_id : UUID <<FK>>
document_id : UUID <<FK>>
position : INTEGER NOT NULL CHECK (position > 0)
note : TEXT CHECK (length <= 2000)
==
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
UNIQUE (geschichte_id, document_id) WHERE document_id IS NOT NULL
}
}
@@ -436,7 +444,7 @@ audit_log }o--o| documents : document_id
geschichten }o--o| app_users : author_id
geschichten_persons }o--|| geschichten : geschichte_id
geschichten_persons }o--|| persons : person_id
geschichten_documents }o--|| geschichten : geschichte_id
geschichten_documents }o--|| documents : document_id
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
@enduml

View File

@@ -66,7 +66,7 @@ package "Supporting" {
entity audit_log
entity geschichten
entity geschichten_persons
entity geschichten_documents
entity journey_items
}
' Auth relationships
@@ -129,7 +129,8 @@ audit_log }o--o| documents : document_id
geschichten }o--o| app_users : author_id
geschichten_persons }o--|| geschichten : geschichte_id
geschichten_persons }o--|| persons : person_id
geschichten_documents }o--|| geschichten : geschichte_id
geschichten_documents }o--|| documents : document_id
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
@enduml

View File

@@ -20,24 +20,19 @@ The observability stack (Prometheus, Loki, Grafana, Tempo, GlitchTip) ships as a
---
## VPS Sizing Recommendations
## Server Sizing
### Recommended: Hetzner CX32
### Current Production Server: Hetzner Dedicated (Serverbörse)
**Specs**: 4 vCPU, 8 GB RAM, 80 GB SSD · **Cost**: 17 EUR/mo
**Specs**: Intel Core i7-6700 (4C/8T, 3.4 GHz), 64 GB RAM · acquired via Hetzner server auction
Sufficient for the application stack (Postgres, MinIO, OCR with `mem_limit: 12g`, backend, frontend, Caddy) on a CX32 today. Once the observability stack lands (Prometheus/Loki/Grafana/Alertmanager add ~2 GB) consider a CX42.
Comfortably handles the full application stack (Postgres, MinIO, OCR with `mem_limit: 12g`, backend, frontend, Caddy, full observability stack) with headroom to spare. The 64 GB RAM means OCR, Ollama inference, and the observability stack can all run concurrently without memory pressure.
### When to Upgrade: Hetzner CX42
### When to Reconsider Hardware
**Specs**: 8 vCPU, 16 GB RAM · **Cost**: 29 EUR/mo
Upgrade when:
- Observability stack adds memory pressure (Loki + Grafana with >30 days retention)
- OCR throughput needs scaling beyond a single-node Surya/Kraken setup
- Real user load profiled in Grafana shows response-time degradation
Never upgrade the VPS tier before profiling — most perceived performance issues are application bugs, not resource constraints.
- CPU is Skylake (2015) — single-threaded performance is the likely bottleneck before RAM
- Profile with Grafana dashboards before concluding hardware is the constraint
- Most perceived performance issues are application bugs (unindexed queries, N+1 loads), not resource limits
---
@@ -45,12 +40,11 @@ Never upgrade the VPS tier before profiling — most perceived performance issue
| Service | Cost |
|---|---|
| Hetzner CX32 VPS | 17.00 EUR |
| Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM) | see invoice |
| Hetzner DNS | 0.00 EUR |
| Hetzner SMTP relay | ~1.00 EUR |
| **Total** | **~18 EUR/mo** |
MinIO data lives on the VPS disk (no Object Storage line item yet). The Hetzner OBS migration would add ~5 EUR/mo at ~200 GB.
MinIO data lives on the server disk (no Object Storage line item yet). The Hetzner OBS migration would add ~5 EUR/mo at ~200 GB.
Equivalent SaaS stack: 200300 EUR/mo.

View File

@@ -0,0 +1,808 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Lesereisen — Journey-Editor · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
.jh-o .jn{color:var(--orange);}
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
.fa-link.active{color:var(--mint);}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
/* ── impl-ref table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
.at tr:last-child td{border-bottom:none;}
.at td:first-child{color:#7A7A72;}
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
.at td:nth-child(3){color:#5A5A55;}
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* ── LLM guide ── */
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
/* ── Editor chrome (shared with writer spec) ── */
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
.ed-status-pub{background:var(--green-tint);color:var(--green-dark);border:1px solid #A0D8A8;}
.ed-delete-link{font-size:8px;font-weight:600;color:#DC4C3E;margin-left:8px;}
.ed-split{display:flex;flex:1;overflow:hidden;}
.ed-sidebar{width:210px;flex-shrink:0;border-left:1px solid #e4e2d7;background:#fff;display:flex;flex-direction:column;overflow-y:auto;}
.ed-sb-section{padding:12px 12px 10px;}
.ed-sb-title{font-size:8px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
.ed-sb-divider{height:1px;background:#e4e2d7;}
.ed-search-row{display:flex;align-items:center;gap:6px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px 8px;margin-bottom:6px;}
.ed-search-input{font-size:9px;color:var(--color-text-muted);}
.ed-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 7px;background:var(--sand);border:1px solid var(--color-border);border-radius:12px;font-size:8px;font-weight:500;color:var(--color-text);margin:0 4px 4px 0;}
.ed-chip-x{color:var(--color-text-muted);font-size:9px;cursor:pointer;margin-left:2px;}
.ed-hint{font-size:8px;color:var(--color-text-muted);line-height:1.5;margin-top:4px;}
.ed-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:9px 14px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;gap:10px;}
.ed-savebar-hint{font-size:8px;color:var(--color-text-muted);}
.ed-savebar-actions{display:flex;align-items:center;gap:7px;}
.ed-btn-ghost{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);border:1px solid var(--color-border);color:var(--color-text);background:#fff;cursor:pointer;white-space:nowrap;}
.ed-btn-ghost.retract{color:#B46820;border-color:#E8D5B0;}
.ed-btn-primary{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);background:var(--navy);color:#fff;border:none;cursor:pointer;white-space:nowrap;}
/* ── Journey Editor main area ── */
.je-main{flex:1;display:flex;flex-direction:column;padding:14px 16px;overflow-y:auto;gap:8px;background:var(--color-page);}
.je-title-input{font-family:var(--font-display);font-size:15px;font-weight:400;color:var(--color-text);border:none;border-bottom:1px solid var(--color-border);padding:4px 0 6px;width:100%;outline:none;background:transparent;letter-spacing:-.01em;}
.je-title-input.placeholder{color:var(--color-text-muted);font-style:italic;}
.je-sep{height:1px;background:var(--color-border);margin:2px 0;}
.je-intro-area{font-family:Georgia,serif;font-size:9px;line-height:1.7;color:var(--color-text-muted);font-style:italic;border:none;padding:5px 0;width:100%;outline:none;background:transparent;min-height:36px;resize:none;}
.je-intro-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:2px;}
.je-list-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:5px;margin-top:4px;}
/* ── Item rows ── */
.je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:4px;margin-bottom:5px;overflow:hidden;}
.je-drag{width:16px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;cursor:grab;flex-shrink:0;}
.je-drag-dots{display:flex;flex-direction:column;gap:2px;}
.je-drag-dot{width:3px;height:3px;border-radius:50%;background:#C4C3BC;}
.je-num{width:20px;display:flex;align-items:flex-start;justify-content:center;padding-top:8px;font-size:8px;font-weight:700;color:#9B9A93;flex-shrink:0;}
.je-body{flex:1;padding:7px 8px 7px 4px;}
.je-doc-title{font-size:9px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:2px;}
.je-doc-meta{font-size:7.5px;color:var(--color-text-muted);margin-bottom:5px;}
.je-note-area{width:100%;min-height:32px;font-family:Georgia,serif;font-size:8px;line-height:1.55;color:var(--color-text);font-style:italic;border:1px solid var(--color-border);border-radius:3px;background:var(--color-surface);padding:4px 6px;resize:none;outline:none;}
.je-note-add{font-size:7.5px;font-weight:600;color:var(--blue);cursor:pointer;display:inline-flex;align-items:center;gap:2px;}
.je-remove{width:24px;display:flex;align-items:flex-start;justify-content:center;padding-top:7px;flex-shrink:0;}
.je-remove-x{font-size:11px;color:#C4C3BC;cursor:pointer;line-height:1;font-weight:300;}
.je-interlude-bg{background:var(--orange-tint);border-color:#F0C99A;}
.je-interlude-icon{font-size:8px;color:var(--orange);margin-bottom:2px;}
.je-interlude-area{width:100%;min-height:36px;font-family:Georgia,serif;font-size:8px;line-height:1.6;color:var(--color-text);font-style:italic;border:1px solid #F0C99A;border-radius:3px;background:rgba(255,255,255,.6);padding:4px 6px;resize:none;outline:none;}
.je-empty{padding:16px;text-align:center;border:1px dashed var(--color-border);border-radius:4px;background:var(--color-surface);}
.je-empty-text{font-family:Georgia,serif;font-size:8px;color:var(--color-text-muted);font-style:italic;}
/* ── Add bar ── */
.je-add-bar{display:flex;gap:7px;padding:6px 0 4px;}
.je-add-btn{font-size:8px;font-weight:600;padding:5px 10px;border-radius:3px;border:1px dashed var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:3px;}
.je-add-btn:hover{border-color:var(--navy);color:var(--navy);}
/* ── Inline note editing state (highlight) ── */
.je-note-editing{border-color:var(--navy);background:#fff;}
/* ── Mobile journey editor ── */
.mob-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 10px;gap:6px;height:34px;flex-shrink:0;}
.mob-back{font-size:8px;color:var(--color-text-muted);}
.mob-label{font-family:var(--font-sans);font-size:9px;font-weight:500;color:var(--color-text);flex:1;}
.mob-body{flex:1;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:7px;background:var(--color-page);}
.mob-title-input{font-family:var(--font-display);font-size:13px;color:var(--color-text-muted);font-style:italic;border:none;border-bottom:1px solid var(--color-border);padding:3px 0 5px;width:100%;background:transparent;outline:none;}
.mob-collapsible{background:#fff;border:1px solid #e4e2d7;border-radius:3px;overflow:hidden;}
.mob-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:7px 9px;font-size:8.5px;font-weight:600;color:var(--color-text);}
.mob-coll-chevron{font-size:9px;color:var(--color-text-muted);}
.mob-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:8px 10px;display:flex;gap:6px;flex-shrink:0;}
.mob-btn{font-size:8.5px;font-weight:600;padding:7px 0;border-radius:3px;text-align:center;flex:1;}
.mob-btn-ghost{border:1px solid var(--color-border);color:var(--color-text);background:#fff;}
.mob-btn-primary{background:var(--navy);color:#fff;border:none;}
.mob-je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:3px;margin-bottom:4px;overflow:hidden;}
.mob-je-drag{width:14px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;}
.mob-je-body{flex:1;padding:6px 7px;}
.mob-je-title{font-size:8.5px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:1px;}
.mob-je-meta{font-size:7px;color:var(--color-text-muted);}
.mob-je-note{margin-top:4px;padding:3px 5px;background:var(--color-surface);border-left:2px solid var(--mint);font-size:7.5px;font-style:italic;color:var(--color-text-muted);}
.mob-je-interlude{background:var(--orange-tint);border-color:#F0C99A;}
.mob-je-interlude-text{font-size:7.5px;font-style:italic;color:var(--color-text);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ DOC HEADER ═══ -->
<div class="doc-header">
<div>
<h1>Lesereisen — Journey-Editor</h1>
<p>Kuratierungs-Oberfläche für <code>JourneyEditor</code> auf <code>/geschichten/[id]/edit</code> (wenn <code>type === 'JOURNEY'</code>). Geordnete Briefliste mit Drag-to-Reorder, Dokumenten-Picker, Interlude-Notizen und Inline-Annotation-Editing. Ersetzt den TipTap-Editor für den Journey-Typ.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-o">Final Spec</span><br/>
2026-06-07 &middot; @leonievoss<br/>
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #753</span>
</div>
</div>
<!-- ═══ JOURNEY HEADER ═══ -->
<div class="jh jh-o">
<div class="jn">E</div>
<div>
<h2>Journey-Editor</h2>
<p>BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.</p>
<div class="fl">/geschichten/[id]/edit (type === 'JOURNEY')</div>
</div>
</div>
<!-- ═══ KONZEPT ═══ -->
<div class="section">
<div class="section-title">Konzept</div>
<p class="prose">Der <code>JourneyEditor</code> ist eine parallele Implementierung zum bestehenden <code>GeschichteEditor</code> und wird auf derselben Edit-Route eingeblendet wenn <code>type === 'JOURNEY'</code>. Das Split-Layout (70/30) bleibt erhalten: links die Briefliste, rechts die Sidebar mit Personen und Status.</p>
<p class="prose">Die linke Fläche zeigt: oben einen optionalen Einleitungs-Textarea (<code>body</code>), darunter die geordnete Itemliste, ganz unten eine Aktionsleiste mit „+ Brief hinzufügen" und „+ Zwischentext hinzufügen". Jedes Item hat einen Drag-Handle, eine Positionsnummer, den Inhalt und einen Entfernen-Button.</p>
<p class="prose">Dokument-Items zeigen Titel und Kurz-Metadaten. Eine „Notiz hinzufügen/bearbeiten"-Aktion expandiert ein Textarea direkt unterhalb des Items — kein Modal, kein separates Formular. Interlude-Items (reiner Zwischentext) zeigen direkt ein editierbares Textarea mit orangenem Hintergrund zur klaren visuellen Unterscheidung.</p>
<p class="prose">Speicheraktionen: Speichern (bei veröffentlichter Journey) oder „Entwurf speichern" + „Veröffentlichen" (bei DRAFT). Die Savebarlogik ist identisch zum GeschichteEditor. Alle Mutationen lösen sofort einen API-Call aus und aktualisieren den lokalen Zustand optimistisch — kein separates Save für einzelne Items.</p>
</div>
<!-- ═══ SCREEN LE-1: EMPTY EDITOR ═══ -->
<div class="section">
<div class="section-title">Screens — Leerer Editor</div>
<div class="scr">
<div class="scr-head">
<h3>LE-1 — Journey-Editor leer</h3>
<span class="scr-id">Issue #753 · LE-1</span>
</div>
<p class="scr-desc">Ausgangszustand einer neuen oder leeren Lesereise. Titel-Input oben. Darunter ein optionaler Einleitungs-Textarea. Leere Itemliste mit Leerstate-Text. Aktionsleiste mit zwei Buttons. Sidebar: Personen-Verknüpfung und Status-Anzeige. Keine Items → „Veröffentlichen" noch nicht aktiv (Disabled-Hint erscheint).</p>
<p class="scr-var"><strong>Varianten:</strong> Neuer Entwurf ohne Titel (hier gezeigt) · Mit Titel, leere Liste</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · Neuer Entwurf</span>
<div class="desk" style="min-height:500px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
Neue Lesereise
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
</div>
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
</div>
<div class="ed-split">
<!-- Left: Journey editor area -->
<div class="je-main">
<input class="je-title-input placeholder" type="text" value="" placeholder="Titel der Lesereise" readonly/>
<div class="je-sep"></div>
<div>
<div class="je-intro-label">Einleitung (optional)</div>
<textarea class="je-intro-area" placeholder="Optionaler Einleitungstext für diese Lesereise…" readonly></textarea>
</div>
<div class="je-sep"></div>
<div class="je-list-label">Briefe &amp; Zwischentexte</div>
<div class="je-empty">
<div class="je-empty-text">Noch keine Einträge. Füge den ersten Brief oder einen Zwischentext hinzu.</div>
</div>
<div class="je-add-bar">
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Brief hinzufügen
</button>
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Zwischentext hinzufügen
</button>
</div>
</div>
<!-- Right: Sidebar -->
<div class="ed-sidebar">
<div class="ed-sb-section">
<div class="ed-sb-title">Personen</div>
<div class="ed-search-row">
<span style="font-size:9px;color:var(--color-text-muted);">&#128269;</span>
<div class="ed-search-input">Person suchen…</div>
</div>
<div class="ed-hint">Welche historischen Personen kommen in dieser Lesereise vor?</div>
</div>
<div class="ed-sb-divider"></div>
<div class="ed-sb-section">
<div class="ed-sb-title">Status</div>
<div class="ed-status-pill ed-status-draft" style="font-size:9px;">ENTWURF</div>
<div class="ed-hint" style="margin-top:6px;">Noch nicht öffentlich sichtbar. Füge mindestens einen Brief hinzu, um zu veröffentlichen.</div>
</div>
</div>
</div>
<div class="ed-savebar">
<span class="ed-savebar-hint">Alle Änderungen werden als Entwurf gespeichert.</span>
<div class="ed-savebar-actions">
<button class="ed-btn-ghost">Entwurf speichern</button>
<button class="ed-btn-primary" style="opacity:.4;cursor:not-allowed;">Veröffentlichen</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-1 Leerer Editor</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Bedingte Verzweigung</td><td>{#if geschichte.type === 'JOURNEY'}&lt;JourneyEditor /&gt;{:else}&lt;GeschichteEditor /&gt;{/if}</td><td>in edit/+page.svelte; Props: geschichte: Geschichte</td></tr>
<tr><td>Split-Layout</td><td>flex flex-1 overflow-hidden (gleich wie GeschichteEditor)</td><td>70/30; Sidebar identisch</td></tr>
<tr><td>Topbar-Badge</td><td>„REISE" Pill neben dem Titel-Label</td><td>orange; kein interaktives Element; zeigt Typ</td></tr>
<tr class="grp"><td colspan="3">Titel-Input</td></tr>
<tr><td>Titel-Input</td><td>font-serif text-2xl border-b border-line pb-2 w-full bg-transparent outline-none</td><td>bind:value={title}; gleiche Validierung wie GeschichteEditor (required)</td></tr>
<tr class="grp"><td colspan="3">Einleitungs-Textarea</td></tr>
<tr><td>Intro-Textarea</td><td>font-serif text-sm italic text-ink-3 leading-relaxed w-full resize-none bg-transparent outline-none border-none py-1</td><td>bind:value={body}; plaintext; auto-resize per rows-attr oder JS</td></tr>
<tr><td>Label</td><td>text-[10px] font-bold uppercase tracking-widest text-ink-3 mb-1</td><td>„EINLEITUNG (OPTIONAL)"</td></tr>
<tr class="grp"><td colspan="3">Leerstate</td></tr>
<tr><td>Leerstate-Container</td><td>py-8 text-center border border-dashed border-line rounded-sm bg-surface</td><td>verschwindet sobald erstes Item vorhanden</td></tr>
<tr><td>Leerstate-Text</td><td>font-serif text-xs text-ink-3 italic</td><td></td></tr>
<tr class="grp"><td colspan="3">Veröffentlichen-Button</td></tr>
<tr><td>Disabled-Zustand</td><td>disabled={items.length === 0 || !title.trim()}</td><td>opacity-40 + cursor-not-allowed; keine Tooltip nötig — Sidebar-Hint erklärt es</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-2: EDITOR WITH ITEMS ═══ -->
<div class="section">
<div class="section-title">Screens — Editor mit Einträgen</div>
<div class="scr">
<div class="scr-head">
<h3>LE-2 — Journey-Editor mit Einträgen</h3>
<span class="scr-id">Issue #753 · LE-2</span>
</div>
<p class="scr-desc">Gefüllte Itemliste mit gemischten Typen: Dokument-Item ohne Notiz, Interlude-Item (reiner Zwischentext), Dokument-Item mit bestehender Notiz. Jedes Item zeigt Drag-Handle links, Positionsnummer, Inhalt und Entfernen-Button. Aktionsleiste bleibt unter der Liste sichtbar.</p>
<p class="scr-var"><strong>Varianten:</strong> Veröffentlichte Journey (hier gezeigt) · Entwurf · Mobile</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · VERÖFFENTLICHT</span>
<div class="desk" style="min-height:580px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">KR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
Lesereise bearbeiten
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
</div>
<div class="ed-status-pill ed-status-pub">VERÖFFENTLICHT</div>
<span class="ed-delete-link">Löschen</span>
</div>
<div class="ed-split">
<!-- Left: Journey editor area with items -->
<div class="je-main">
<input class="je-title-input" type="text" value="Briefe aus Breslau 19381942" readonly/>
<div class="je-sep"></div>
<div>
<div class="je-intro-label">Einleitung (optional)</div>
<textarea class="je-intro-area" readonly style="color:var(--color-text);">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten Friedenssommern bis zum Ende des Krieges.</textarea>
</div>
<div class="je-sep"></div>
<div class="je-list-label">Briefe &amp; Zwischentexte</div>
<!-- Item 1: Document, no note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">1</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
<div class="je-doc-meta">12. Juli 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item 2: Interlude -->
<div class="je-item je-interlude-bg">
<div class="je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;">
<div class="je-drag-dots">
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
</div>
</div>
<div class="je-num" style="color:var(--orange-dark);">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style="margin-top:7px;"><path d="M2 4h8M2 7h5" stroke="var(--orange)" stroke-width="1.5" stroke-linecap="round"/></svg>
</div>
<div class="je-body" style="padding-top:6px;">
<div style="font-size:7.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:4px;">Zwischentext</div>
<textarea class="je-interlude-area" readonly>Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</textarea>
</div>
<div class="je-remove"><div class="je-remove-x" style="color:#D4A574;">&#215;</div></div>
</div>
<!-- Item 3: Document with note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">2</div>
<div class="je-body">
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 &middot; von Franz Raddatz an Emma Müller</div>
<textarea class="je-note-area" readonly>Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten?</textarea>
<div class="je-note-add" style="margin-top:3px;color:var(--color-text-muted);">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Notiz entfernen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item 4: Document, no note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">3</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 3. September 1939</div>
<div class="je-doc-meta">3. Sept. 1939 &middot; von Emma Müller an Franz Raddatz</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Add bar -->
<div class="je-add-bar">
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Brief hinzufügen
</button>
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Zwischentext hinzufügen
</button>
</div>
</div>
<!-- Right: Sidebar -->
<div class="ed-sidebar">
<div class="ed-sb-section">
<div class="ed-sb-title">Personen</div>
<div class="ed-search-row">
<span style="font-size:9px;color:var(--color-text-muted);">&#128269;</span>
<div class="ed-search-input">Person suchen…</div>
</div>
<div style="display:flex;flex-wrap:wrap;margin-top:4px;">
<span class="ed-chip">
<span style="width:10px;height:10px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);">FR</span>
Franz Raddatz
<span class="ed-chip-x">&#215;</span>
</span>
<span class="ed-chip">
<span style="width:10px;height:10px;border-radius:50%;background:#534AB7;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;">EM</span>
Emma Müller
<span class="ed-chip-x">&#215;</span>
</span>
</div>
</div>
<div class="ed-sb-divider"></div>
<div class="ed-sb-section">
<div class="ed-sb-title">Status</div>
<div class="ed-status-pill ed-status-pub" style="font-size:9px;">VERÖFFENTLICHT</div>
<div class="ed-hint" style="margin-top:6px;">Änderungen gehen sofort live.</div>
</div>
</div>
</div>
<div class="ed-savebar">
<span class="ed-savebar-hint">Änderungen sofort live — Leser sehen die aktuelle Version.</span>
<div class="ed-savebar-actions">
<button class="ed-btn-ghost retract">Zurück zu Entwurf</button>
<button class="ed-btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-2 Items-Liste</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: <del>bg-orange-50 border-orange-200</del><code>--color-interlude-bg</code> / <code>--color-interlude-border</code> CSS tokens</td></tr>
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
<tr><td>„Notiz hinzufügen" Link</td><td><del>text-xs font-semibold text-blue-600</del><code>text-xs text-ink-3 underline hover:text-accent</code></td><td>togglet Notiz-Textarea</td></tr>
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Container</td><td><del>bg-orange-50 border-orange-200</del><code>--color-interlude-bg</code> left-accent border via <code>--color-interlude-border</code></td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
<tr><td>Label „Zwischentext"</td><td><del>text-orange-700</del><code>color: var(--color-interlude-label)</code></td><td>immer sichtbar; nicht editierbar</td></tr>
<tr><td>Zwischentext-Textarea</td><td><del>border-orange-200 focus:border-orange-400</del><code>border-line focus-visible:ring-focus-ring</code></td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
<tr><td>Bibliothek</td><td><del>@dnd-kit/core oder svelte-dnd-action</del><code>createBlockDragDrop&lt;JourneyItemView&gt;</code> aus <code>$lib/document/transcription/useBlockDragDrop.svelte</code></td><td>kein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt</td></tr>
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-3: INLINE NOTE EDITING ═══ -->
<div class="section">
<div class="section-title">Screens — Inline-Notiz-Editing</div>
<div class="scr">
<div class="scr-head">
<h3>LE-3 — Notiz-Textarea wird geöffnet</h3>
<span class="scr-id">Issue #753 · LE-3</span>
</div>
<p class="scr-desc">Wenn der Nutzer auf „Notiz hinzufügen" klickt, expandiert das Item um ein Textarea direkt unterhalb der Briefmeta — kein Modal. Der Fokus springt automatisch in das Textarea. Das Textarea hat einen blauen Fokusring als Orientierungshilfe. Ein API-PATCH wird beim Verlassen des Textareas (blur) ausgelöst, nicht bei jedem Tastendruck.</p>
<p class="scr-var"><strong>Inset-Ansicht — kein vollständiger Seiten-Mockup nötig</strong></p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:560px;">
<span class="bp-lbl">Inset — Notiz-Textarea geöffnet (Fokus)</span>
<div style="background:#E8E7E2;padding:16px;border-radius:var(--radius-xl);">
<!-- Item before (no note) -->
<div class="je-item" style="margin-bottom:5px;">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">1</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
<div class="je-doc-meta">12. Juli 1938 &middot; Franz → Emma</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item with opened note textarea (focused) -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">2</div>
<div class="je-body">
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 &middot; Franz → Emma</div>
<!-- Focused textarea -->
<textarea class="je-note-area je-note-editing" style="outline:none;box-shadow:0 0 0 2px rgba(1,40,81,.2);" readonly placeholder="Kuratoren-Notiz für diesen Brief…">|</textarea>
<div style="font-size:7px;color:var(--color-text-muted);margin-top:3px;">Wird gespeichert, wenn du das Feld verlässt.</div>
<div class="je-note-add" style="color:var(--color-text-muted);margin-top:2px;">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Notiz entfernen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-3 Inline-Notiz</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Toggleverhalten</td></tr>
<tr><td>Lokaler State</td><td>let noteOpen = item.note !== null and item.note !== ''</td><td>öffnet sich automatisch wenn Notiz bereits vorhanden</td></tr>
<tr><td>„Notiz hinzufügen" Klick</td><td>noteOpen = true; tick().then(() => noteTextarea.focus())</td><td>Fokus nach Svelte-Tick um DOM-Update abzuwarten</td></tr>
<tr><td>Textarea blur-Handler</td><td>on:blur={() => saveNote(item.id, note)}</td><td>PATCH /api/geschichten/{id}/items/{itemId} mit {note}</td></tr>
<tr><td>Leere Notiz on blur</td><td>wenn note.trim() === '' → noteOpen = false; note = null</td><td>verhindert leere Notizen im Backend</td></tr>
<tr class="grp"><td colspan="3">Fokus-Styling</td></tr>
<tr><td>Fokus-Ring</td><td>focus:border-primary focus:ring-2 focus:ring-primary/20 focus:bg-white</td><td>sichtbarer Ring für Keyboard-Navigation; ring-offset für Abstand</td></tr>
<tr><td>Spar-Hint</td><td>text-[9px] text-ink-3 mt-1</td><td>„Wird gespeichert, wenn du das Feld verlässt."; verschwindet wenn noteOpen = false</td></tr>
<tr class="grp"><td colspan="3">Barrierefreiheit</td></tr>
<tr><td>aria-label Textarea</td><td>aria-label="Kuratoren-Notiz für {document.title}"</td><td>spezifisch; Screen-Reader nennt Brief-Kontext</td></tr>
<tr><td>aria-expanded Toggle</td><td>aria-expanded={noteOpen} auf „Notiz hinzufügen"-Button</td><td>kommuniziert Expand-State</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-4: MOBILE ═══ -->
<div class="section">
<div class="section-title">Screens — Mobile Editor</div>
<div class="scr">
<div class="scr-head">
<h3>LE-4 — Mobile Journey-Editor</h3>
<span class="scr-id">Issue #753 · LE-4</span>
</div>
<p class="scr-desc">Auf Mobile (320px) entfällt die Sidebar-Split. Die Personen- und Status-Sektion werden als ausklappbare Sektionen unter der Itemliste gezeigt. Drag-to-Reorder ist auf Mobile durch Long-Press aktiviert. Die Aktionsleiste scrollt mit dem Inhalt.</p>
<p class="scr-var"><strong>Primäre Zielgruppe für den Editor: Desktop/Tablet. Mobile ist sekundär — alle Funktionen erreichbar, aber Drag ist schwerer bedienbar.</strong></p>
<div class="previews">
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px · mit Einträgen</span>
<div class="phone" style="min-height:580px;">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">KR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div class="mob-topbar">
<span class="mob-back">&#8592;</span>
<span class="mob-label">Lesereise bearbeiten</span>
<div class="ed-status-pill ed-status-pub" style="font-size:7px;padding:1px 5px;">VER&Ouml;FF.</div>
</div>
<div class="mob-body">
<input class="mob-title-input" type="text" value="Briefe aus Breslau 19381942" readonly/>
<!-- Item 1: Document -->
<div class="mob-je-item">
<div class="mob-je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="mob-je-body">
<div class="mob-je-title">Brief vom 12. Juli 1938</div>
<div class="mob-je-meta">12. Juli 1938 &middot; Franz → Emma</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">&#215;</div>
</div>
<!-- Item 2: Interlude -->
<div class="mob-je-item mob-je-interlude">
<div class="mob-je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;"></div>
<div class="mob-je-body">
<div style="font-size:6.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:3px;">Zwischentext</div>
<div class="mob-je-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht…</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#D4A574;">&#215;</div>
</div>
<!-- Item 3: Document with note -->
<div class="mob-je-item">
<div class="mob-je-drag"></div>
<div class="mob-je-body">
<div class="mob-je-title">Postkarte Aug. 1938</div>
<div class="mob-je-meta">22. Aug. 1938 &middot; Franz → Emma</div>
<div class="mob-je-note">Diese Karte ist ungewöhnlich kurz für Franz…</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">&#215;</div>
</div>
<!-- Add bar -->
<div style="display:flex;gap:5px;padding:4px 0;">
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Brief</button>
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Zwischentext</button>
</div>
<!-- Collapsible: Personen -->
<div class="mob-collapsible">
<div class="mob-coll-hdr">
Personen
<span class="mob-coll-chevron">&#8250;</span>
</div>
</div>
<!-- Collapsible: Status -->
<div class="mob-collapsible">
<div class="mob-coll-hdr">
Status &amp; Speichern
<span class="mob-coll-chevron">&#8250;</span>
</div>
</div>
</div>
<div class="mob-savebar">
<button class="mob-btn mob-btn-ghost" style="font-size:8px;flex:0 0 auto;padding:7px 10px;">Entwurf</button>
<button class="mob-btn mob-btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-4 Mobile</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Layout-Anpassungen</td></tr>
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
<tr class="grp"><td colspan="3">Touch &amp; Drag</td></tr>
<tr><td>Drag auf Mobile</td><td>Move-Up/Down Buttons statt Drag (44px touch targets)</td><td><del>dnd-kit unterstützt Touch nativ</del> → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons</td></tr>
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
<tr class="grp"><td colspan="3">Savebar</td></tr>
<tr><td>Savebar Mobile</td><td>flex gap-2; „Zurück zu Entwurf" komprimiert zu „Entwurf"</td><td>Volltext passt nicht auf 320px</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide — Journey-Editor</h2>
<h3>Neue Komponente</h3>
<table>
<thead><tr><th>Datei</th><th>Typ</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><code>src/lib/geschichte/JourneyEditor.svelte</code></td><td>Svelte-Komponente</td><td>Hauptkomponente; Props: <code>geschichte: Geschichte</code></td></tr>
<tr><td><code>src/lib/geschichte/JourneyItemRow.svelte</code></td><td>Svelte-Komponente</td><td>Eine Zeile (Dokument oder Interlude); Props: <code>item: JourneyItem, position: number</code>, Events: <code>remove, noteChange</code></td></tr>
</tbody>
</table>
<h3>Edit-Page-Integration</h3>
<ul>
<li><code>GeschichteEditor.svelte</code> erhält ein neues Prop <code>type: GeschichteType</code>.</li>
<li>Wenn <code>type === 'JOURNEY'</code>: rendere <code>JourneyEditor</code> statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.</li>
<li>Die Savebar-Logik ist in der Edit-Page (<code>+page.svelte</code>) verankert — <code>JourneyEditor</code> gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.</li>
</ul>
<h3>API-Calls</h3>
<table>
<thead><tr><th>Aktion</th><th>Endpoint</th><th>Body</th></tr></thead>
<tbody>
<tr><td>Brief hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{documentId: UUID}</code></td></tr>
<tr><td>Zwischentext hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{note: string}</code></td></tr>
<tr><td>Notiz speichern/bearbeiten</td><td><code>PATCH /api/geschichten/{id}/items/{itemId}</code></td><td><code>{note: string | null}</code></td></tr>
<tr><td>Item entfernen</td><td><code>DELETE /api/geschichten/{id}/items/{itemId}</code></td><td></td></tr>
<tr><td>Reihenfolge speichern</td><td><code>PUT /api/geschichten/{id}/items/reorder</code></td><td><code>[{id: UUID, position: number}]</code></td></tr>
</tbody>
</table>
<h3>Optimistische Updates</h3>
<ul>
<li>Alle Mutationen (add, remove, reorder, noteChange) aktualisieren den lokalen State <em>sofort</em>, der API-Call läuft parallel.</li>
<li>Bei Fehler: lokalen State zurückrollen und einen <code>aria-live="polite"</code>-Fehlerhinweis anzeigen.</li>
<li>Notiz-Saving ist ein Sonderfall: es gibt kein optimistisches Update da der Wert bereits live im Textarea ist — nur blur → PATCH.</li>
</ul>
<h3>DocumentPicker-Integration</h3>
<ul>
<li>Der „Brief hinzufügen"-Button öffnet die bestehende <code>DocumentPicker</code>-Komponente (prüfe <code>$lib/document/</code> auf vorhandene Typeahead-Komponenten).</li>
<li>Nach Auswahl eines Dokuments: <code>POST /items</code> mit <code>documentId</code>, neues Item wird an das Ende der Liste angehängt und eingeblendet.</li>
<li>Bereits in der Journey enthaltene Dokumente: in der Picker-Ergebnisliste mit einem „Bereits enthalten"-Hinweis markieren und deaktivieren.</li>
</ul>
<h3>Drag-to-Reorder</h3>
<ul>
<li><del>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist.</del> → Implementiert mit <code>createBlockDragDrop&lt;JourneyItemView&gt;</code> (kein externes Package).</li>
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
</ul>
<h3>Barrierefreiheit</h3>
<ul>
<li>Items-Liste: <code>&lt;ol&gt;</code>-Element — kommuniziert die Ordnung an Screenreader.</li>
<li>Drag-Handle: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Reihenfolge von '{title}' ändern"</code>.</li>
<li>Entfernen-Button: <code>aria-label="'{title}' entfernen"</code>; kein reines ×-Zeichen ohne Label.</li>
<li>Notiz-Textarea: <code>aria-label="Kuratoren-Notiz für '{title}'"</code>.</li>
<li>Touch-Targets: alle interaktiven Elemente min 44×44px (WCAG 2.2 AA).</li>
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Buttons und Textareas.</li>
</ul>
<h3>Abgrenzung zu GeschichteEditor</h3>
<ul>
<li>TipTap wird für JOURNEY <em>nicht</em> geladen — kein unnötiger Bundle-Load.</li>
<li>Die Sidebar (Personen, Status) ist für beide Typen identisch — kein Duplikat, die Sidebar-Komponente wird geteilt.</li>
<li>Savebar-Logik (DRAFT/PUBLISHED/Retract) ist identisch — JourneyEditor ändert sie nicht.</li>
<li><code>Geschichte.body</code> dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,727 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Lesereisen — Reader-Integration · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
.jh-o .jn{color:var(--orange);}
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
.fa-link.active{color:var(--mint);}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
/* ── impl-ref table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
.at tr:last-child td{border-bottom:none;}
.at td:first-child{color:#7A7A72;}
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
.at td:nth-child(3){color:#5A5A55;}
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* ── LLM guide ── */
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
/* ── List row (re-used from reader-journey spec) ── */
.g-list-card{background:#fff;border:1px solid #E4E2D7;border-radius:4px;box-shadow:var(--shadow-card);overflow:hidden;}
.g-row{display:flex;gap:0;border-bottom:1px solid #F0EFE9;}
.g-row:last-child{border-bottom:none;}
.g-meta{width:88px;flex-shrink:0;padding:10px 10px 10px 12px;display:flex;flex-direction:column;gap:3px;border-right:1px solid #F0EFE9;}
.g-content{padding:10px 14px 10px 12px;flex:1;min-width:0;}
.g-av{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;margin-bottom:3px;}
.av-navy{background:#012851;} .av-purple{background:#534AB7;} .av-teal{background:#0E9488;}
.g-author{font-size:7px;font-weight:700;color:#1C1C18;line-height:1.3;}
.g-date{font-size:6.5px;color:#6B6A63;}
.g-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;background:#F5F4EE;border:1px solid #D8D7D0;border-radius:10px;font-size:6px;font-weight:500;color:#1C1C18;margin-top:2px;max-width:76px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.g-title{font-family:Georgia,serif;font-size:11px;color:#012851;line-height:1.4;margin-bottom:2px;}
.g-excerpt{font-size:7.5px;color:#6B6A63;line-height:1.55;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.g-filters{display:flex;gap:5px;align-items:center;padding:8px 12px;background:var(--color-page);border-bottom:1px solid #EDECEA;flex-wrap:wrap;}
.g-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:6.5px;font-weight:700;border:1px solid #D8D7D0;color:#6B6A63;background:transparent;}
.g-pill.active{background:#012851;color:#fff;border-color:#012851;}
.g-page-hdr{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px;}
.g-page-title{font-family:Georgia,serif;font-size:16px;font-weight:400;color:#012851;}
.g-new-btn{font-size:7px;font-weight:700;padding:4px 10px;border-radius:3px;background:#012851;color:#fff;border:none;display:flex;align-items:center;gap:3px;}
/* ── Journey badge in list ── */
.j-badge{display:inline-flex;align-items:center;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-top:2px;}
/* ── Type selector cards ── */
.type-selector{display:flex;gap:12px;justify-content:center;padding:20px 24px;flex:1;align-items:center;background:#E8E7E2;}
.type-selector-inner{max-width:520px;width:100%;}
.type-selector-q{font-family:Georgia,serif;font-size:12px;font-weight:400;color:#6B6A63;text-align:center;margin-bottom:14px;}
.type-cards{display:flex;gap:10px;}
.type-card{flex:1;border:1px solid #D8D7D0;border-radius:6px;padding:12px 14px;cursor:pointer;background:#fff;display:flex;flex-direction:column;gap:5px;}
.type-card.selected{border-color:var(--orange);background:var(--orange-tint);box-shadow:0 0 0 2px rgba(232,134,42,.15);}
.type-card-icon{font-size:16px;margin-bottom:2px;}
.type-card-title{font-family:Georgia,serif;font-size:11px;font-weight:400;color:var(--navy);}
.type-card-desc{font-size:7.5px;color:#6B6A63;line-height:1.55;}
.type-card-check{width:14px;height:14px;border-radius:50%;background:var(--orange);display:flex;align-items:center;justify-content:center;margin-top:4px;align-self:flex-end;}
.type-card-check svg{width:8px;height:8px;}
.type-next-bar{display:flex;justify-content:flex-end;padding:8px 24px;background:#fff;border-top:1px solid #E4E2D7;}
.type-next-btn{font-size:8px;font-weight:700;padding:5px 14px;border-radius:3px;background:var(--navy);color:#fff;border:none;display:flex;align-items:center;gap:3px;}
/* ── Journey reader ── */
.jr-article{background:var(--color-page);border-radius:6px;padding:16px 20px;max-width:640px;margin:0 auto;}
.jr-back{font-size:7px;color:#6B6A63;margin-bottom:10px;display:flex;align-items:center;gap:2px;}
.jr-badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:5px;}
.jr-title{font-family:Georgia,serif;font-size:18px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:8px;}
.jr-metabar{display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid #EDECEA;margin-bottom:10px;}
.jr-metabar-r{margin-left:auto;display:flex;align-items:center;gap:6px;}
.jr-edit-btn{font-size:6.5px;font-weight:600;padding:2px 7px;border:1px solid #D8D7D0;border-radius:3px;color:#1C1C18;background:transparent;}
.jr-intro{font-family:Georgia,serif;font-size:8.5px;line-height:1.75;color:#6B6A63;font-style:italic;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed #EDECEA;}
/* Journey items in reader */
.jr-item{display:flex;gap:7px;margin-bottom:9px;align-items:flex-start;}
.jr-num{width:18px;height:18px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;margin-top:1px;}
.jr-card{flex:1;background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:7px 9px;}
.jr-card-title{font-family:Georgia,serif;font-size:9px;color:#012851;line-height:1.3;margin-bottom:2px;font-weight:400;}
.jr-card-meta{font-size:6.5px;color:#6B6A63;margin-bottom:5px;}
.jr-card-link{font-size:7px;font-weight:600;color:#012851;display:flex;align-items:center;gap:2px;}
.jr-annotation{margin-top:6px;padding:5px 7px;border-left:2px solid var(--mint);background:#F5F4EE;border-radius:0 3px 3px 0;}
.jr-annotation-text{font-size:7.5px;font-style:italic;color:#6B6A63;line-height:1.55;}
.jr-interlude{margin:10px 0 10px 25px;padding:7px 9px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 4px 4px 0;}
.jr-interlude-text{font-size:8px;font-style:italic;color:#1C1C18;line-height:1.65;}
/* Mobile list row */
.m-row{padding:9px 10px;border-bottom:1px solid #F0EFE9;background:#fff;}
.m-row-top{display:flex;align-items:center;gap:5px;margin-bottom:3px;}
.m-author-name{font-size:7px;font-weight:700;color:#1C1C18;}
.m-date{font-size:6.5px;color:#6B6A63;margin-left:auto;}
.m-title{font-family:Georgia,serif;font-size:10px;color:#012851;line-height:1.4;margin-bottom:2px;}
.m-excerpt{font-size:7px;color:#6B6A63;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.m-filters{display:flex;gap:4px;padding:6px 10px;background:var(--color-page);border-bottom:1px solid #EDECEA;overflow-x:auto;flex-wrap:nowrap;}
.m-filters::-webkit-scrollbar{display:none;}
/* Mobile journey reader */
.mjr-article{background:#fff;border-radius:6px;padding:12px 12px 16px;}
.mjr-back{font-size:7px;color:#6B6A63;margin-bottom:7px;display:flex;align-items:center;gap:2px;}
.mjr-badge{display:inline-flex;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:4px;}
.mjr-title{font-family:Georgia,serif;font-size:14px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:6px;}
.mjr-metabar{display:flex;align-items:center;gap:5px;padding-bottom:6px;border-bottom:1px solid #EDECEA;margin-bottom:8px;}
.mjr-intro{font-family:Georgia,serif;font-size:8px;line-height:1.7;color:#6B6A63;font-style:italic;margin-bottom:9px;padding-bottom:7px;border-bottom:1px dashed #EDECEA;}
.mjr-item{display:flex;gap:5px;margin-bottom:7px;align-items:flex-start;}
.mjr-num{width:14px;height:14px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;margin-top:1px;}
.mjr-card{flex:1;background:#F5F4EE;border:1px solid #E4E2D7;border-radius:4px;padding:5px 7px;}
.mjr-card-title{font-family:Georgia,serif;font-size:8.5px;color:#012851;line-height:1.3;margin-bottom:1px;}
.mjr-card-meta{font-size:6px;color:#6B6A63;margin-bottom:4px;}
.mjr-card-link{font-size:6.5px;font-weight:600;color:#012851;}
.mjr-interlude{margin:7px 0 7px 19px;padding:5px 7px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 3px 3px 0;}
.mjr-interlude-text{font-size:7.5px;font-style:italic;color:#1C1C18;line-height:1.6;}
/* ── Editor topbar (type selector screen) ── */
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ DOC HEADER ═══ -->
<div class="doc-header">
<div>
<h1>Lesereisen — Reader-Integration</h1>
<p>Typauswahl bei <code>/geschichten/new</code>, Journey-Badge auf der Übersichtsliste und die neue geordnete Leseansicht auf <code>/geschichten/[id]</code> wenn <code>type === 'JOURNEY'</code>. Bestehende Story-Ansichten bleiben unverändert.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-o">Final Spec</span><br/>
2026-06-07 &middot; @leonievoss<br/>
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #752</span>
</div>
</div>
<!-- ═══ JOURNEY HEADER ═══ -->
<div class="jh jh-o">
<div class="jn">R</div>
<div>
<h2>Lesereisen — Reader</h2>
<p>Alle angemeldeten Familienmitglieder können Lesereisen entdecken und in Briefsequenzen mit Kuratoren-Notizen eintauchen. BLOG_WRITERs sehen zusätzlich Bearbeiten/Löschen-Aktionen.</p>
<div class="fl">/geschichten &middot; /geschichten/new &middot; /geschichten/[id]</div>
</div>
</div>
<!-- ═══ KONZEPT ═══ -->
<div class="section">
<div class="section-title">Konzept</div>
<p class="prose">Eine <em>Lesereise</em> ist eine <code>Geschichte</code> mit <code>type === 'JOURNEY'</code>. Ihr Kerninhalt ist eine geordnete Sequenz von Briefen (<code>JourneyItem</code>s mit <code>document_id</code>) und Zwischentexten (<code>JourneyItem</code>s ohne <code>document_id</code>). Das optionale Feld <code>body</code> dient als Einleitung/Preface.</p>
<p class="prose">Diese Spec deckt drei Änderungen ab: (1) die Typauswahl auf <code>/geschichten/new</code> als vorgelagerter Schritt, (2) das „REISE"-Badge in der Übersichtsliste, und (3) die neue Journey-Leseansicht auf der Detailseite, die den bestehenden Prosa-Body durch eine nummerierte Briefliste ersetzt.</p>
<p class="prose">Dokument-Items zeigen Titel, Datum, Sender→Empfänger und einen Link zum Brief. Optionale Kuratoren-Notizen erscheinen als Annotation mit Mint-Linker-Rand unter dem Briefeintrag. Interlude-Items (kein Dokument) erscheinen als eingerückte Absätze mit orangenem linken Rand — klar vom Dokumenttyp unterscheidbar, aber harmonisch im Lesefluss.</p>
</div>
<!-- ═══ SCREEN LR-0: TYPE SELECTOR ═══ -->
<div class="section">
<div class="section-title">Screens — Typauswahl</div>
<div class="scr">
<div class="scr-head">
<h3>LR-0 — Typauswahl /geschichten/new</h3>
<span class="scr-id">Issue #752 · LR-0</span>
</div>
<p class="scr-desc">Neuer vorgelagerter Schritt beim Erstellen einer Geschichte. Zwei Karten zur Auswahl: „Geschichte" (Prosa) und „Lesereise" (Briefsequenz). Die ausgewählte Karte wird hervorgehoben. Erst nach Auswahl wird der „Weiter"-Button aktiv. Auswahl bleibt im URL-Param erhalten (<code>?type=JOURNEY</code>).</p>
<p class="scr-var"><strong>Varianten:</strong> Keine Auswahl (Weiter-Button inaktiv) · Lesereise gewählt (hier gezeigt) · Geschichte gewählt</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · Lesereise gewählt</span>
<div class="desk" style="min-height:320px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label">Neue Geschichte</div>
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
</div>
<div class="type-selector">
<div class="type-selector-inner">
<div class="type-selector-q">Was möchtest du erstellen?</div>
<div class="type-cards">
<!-- Story card -->
<div class="type-card">
<div class="type-card-icon">✍️</div>
<div class="type-card-title">Geschichte</div>
<div class="type-card-desc">Freier Prosatext über Familienerlebnisse, Erinnerungen oder historische Einordnungen — mit verlinkten Personen und Dokumenten.</div>
</div>
<!-- Journey card (selected) -->
<div class="type-card selected">
<div class="type-card-icon">📜</div>
<div class="type-card-title">Lesereise</div>
<div class="type-card-desc">Geordnete Briefsequenz mit optionalen Kuratoren-Notizen zwischen den Briefen — für chronologische Korrespondenz-Sammlungen.</div>
<div class="type-card-check">
<svg viewBox="0 0 10 10" fill="none"><path d="M2 5l2.5 2.5L8 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
</div>
</div>
<div class="type-next-bar">
<button class="type-next-btn">
Weiter
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-0 Typauswahl</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Layout</td></tr>
<tr><td>Selector area</td><td>flex flex-1 items-center justify-center bg-canvas px-6 py-10</td><td>zentriert, füllt restliche Höhe</td></tr>
<tr><td>Frage</td><td>font-serif text-sm text-ink-2 text-center mb-4</td><td></td></tr>
<tr><td>Karten-Grid</td><td>flex gap-4</td><td>2 gleich breite Karten; auf Mobile flex-col</td></tr>
<tr class="grp"><td colspan="3">Type-Karte</td></tr>
<tr><td>Karte (inaktiv)</td><td>border border-line rounded-md p-4 bg-white cursor-pointer hover:border-primary hover:bg-surface</td><td>focus-visible:ring-2 focus-visible:ring-primary</td></tr>
<tr><td>Karte (ausgewählt)</td><td>border-2 border-orange-500 bg-orange-50 shadow-sm</td><td>aria-pressed="true"; kein Tailwind-Kürzel — nutze CSS-var(--orange)</td></tr>
<tr><td>Check-Kreis</td><td>w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center self-end mt-2</td><td>nur sichtbar wenn ausgewählt</td></tr>
<tr><td>Kartentitel</td><td>font-serif text-sm text-ink</td><td></td></tr>
<tr><td>Kartenbeschreibung</td><td>text-xs text-ink-3 leading-relaxed mt-1</td><td></td></tr>
<tr class="grp"><td colspan="3">Navigation</td></tr>
<tr><td>Weiter-Button</td><td>rounded border border-primary bg-primary text-white px-4 py-2 text-sm font-medium disabled:opacity-40</td><td>disabled wenn keine Karte ausgewählt</td></tr>
<tr><td>URL-Param</td><td>?type=STORY | ?type=JOURNEY</td><td>per goto() nach Klick auf Weiter; lesefreundlich bookmarkbar</td></tr>
<tr><td>Mobile</td><td>flex-col Karten; volle Breite</td><td>kein Scrollbedarf auf 320px</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LR-1: LIST WITH BADGE ═══ -->
<div class="section">
<div class="section-title">Screens — Übersichtsliste</div>
<div class="scr">
<div class="scr-head">
<h3>LR-1 — Reise-Badge in /geschichten</h3>
<span class="scr-id">Issue #752 · LR-1</span>
</div>
<p class="scr-desc">Die Übersichtsliste erhält ein kleines „REISE"-Badge in der Metaspalte einer Journey-Zeile — unterhalb von Datum und Personenchip. Zeilen mit <code>type === 'STORY'</code> bleiben unverändert. Das Badge ist nicht klickbar, dient als reine visuelle Unterscheidung.</p>
<p class="scr-var"><strong>Varianten:</strong> Mischte Liste (hier gezeigt) · Nur-Journey-Filter · Nur-Story-Ansicht (unverändert)</p>
<div class="previews">
<!-- Desktop -->
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · gemischte Liste</span>
<div class="desk">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:14px 16px;">
<div class="g-page-hdr" style="padding:0 0 8px;">
<span class="g-page-title">Geschichten</span>
<button class="g-new-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
Neue Geschichte
</button>
</div>
<div class="g-list-card">
<div class="g-filters">
<span class="g-pill active">Alle</span>
<span class="g-pill">Franz Raddatz</span>
<span class="g-pill">Emma Müller</span>
<span class="g-pill" style="border-style:dashed;color:#6B6A63;">+ Person wählen</span>
</div>
<!-- Row 1: Story (no badge) -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-navy">MR</div>
<div class="g-author">Maria Raddatz</div>
<div class="g-date">14. März 2025</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
Franz Raddatz
</span>
</div>
<div class="g-content">
<div class="g-title">Der Sommer in Breslau</div>
<div class="g-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg, als die Familie noch vollständig zusammen war und niemand ahnte, was kommen würde…</div>
</div>
</div>
<!-- Row 2: Journey (badge!) -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-purple">KR</div>
<div class="g-author">Klaus Raddatz</div>
<div class="g-date">15. Mai 2025</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
Franz Raddatz
</span>
<span class="j-badge">REISE</span>
</div>
<div class="g-content">
<div class="g-title">Briefe aus Breslau 19381942</div>
<div class="g-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma — von den letzten Friedenssommern bis zum Ende des Krieges.</div>
</div>
</div>
<!-- Row 3: Story -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-teal">GK</div>
<div class="g-author">Gertrud Koch</div>
<div class="g-date">18. Okt. 2024</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#534AB7;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:#fff;flex-shrink:0;">EM</span>
Emma Müller
</span>
</div>
<div class="g-content">
<div class="g-title">Die Hochzeit im Krieg</div>
<div class="g-excerpt">1943, mitten im Chaos — Emma bestand darauf, dass das Fest stattfand. Ihr Bruder kam auf Fronturlaub, drei Tage nur, aber es reichte…</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile -->
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px</span>
<div class="phone">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">MR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;display:flex;flex-direction:column;">
<div style="padding:8px 10px 4px;">
<span style="font-family:Georgia,serif;font-size:13px;color:#012851;">Geschichten</span>
</div>
<div class="m-filters">
<span class="g-pill active" style="font-size:6px;padding:2px 7px;">Alle</span>
<span class="g-pill" style="font-size:6px;padding:2px 7px;">Franz Raddatz</span>
<span class="g-pill" style="font-size:6px;padding:2px 7px;border-style:dashed;">+ Person…</span>
</div>
<div style="background:#fff;flex:1;">
<!-- Story row -->
<div class="m-row">
<div class="m-row-top">
<div class="g-av av-navy" style="width:16px;height:16px;font-size:5.5px;">MR</div>
<span class="m-author-name">Maria Raddatz</span>
<span class="m-date">14. Mrz. 2025</span>
</div>
<div class="m-title">Der Sommer in Breslau</div>
<div class="m-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg…</div>
</div>
<!-- Journey row (badge) -->
<div class="m-row">
<div class="m-row-top">
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;">KR</div>
<span class="m-author-name">Klaus Raddatz</span>
<span class="m-date">15. Mai 2025</span>
</div>
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;">
<div class="m-title" style="margin-bottom:0;">Briefe aus Breslau 19381942</div>
<span class="j-badge" style="flex-shrink:0;">REISE</span>
</div>
<div class="m-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma…</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-1 Journey-Badge in der Liste</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Badge</td></tr>
<tr><td>Journey badge</td><td>inline-flex items-center px-1.5 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide bg-orange-50 text-orange-700 border border-orange-200</td><td>nur wenn type === 'JOURNEY'</td></tr>
<tr><td>Position Desktop</td><td>unterhalb Datum-Text und Personenchip in der Metaspalte (g-meta)</td><td>kein extra Abstand nötig — gap-1 der Flex-Spalte reicht</td></tr>
<tr><td>Position Mobile</td><td>inline flex items-center gap-1.5 neben Titel</td><td>Titel + Badge in einem flex-Wrapper; badge shrink-0</td></tr>
<tr><td>aria-label</td><td>aria-label="Lesereise"</td><td>Badge ist span, kein interaktives Element</td></tr>
<tr class="grp"><td colspan="3">Bedingte Logik</td></tr>
<tr><td>Svelte guard</td><td>{#if geschichte.type === 'JOURNEY'}&lt;span …&gt;REISE&lt;/span&gt;{/if}</td><td>kein Badge für STORY</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LR-2: JOURNEY READER ═══ -->
<div class="section">
<div class="section-title">Screens — Journey-Leseansicht</div>
<div class="scr">
<div class="scr-head">
<h3>LR-2 — Journey-Detail /geschichten/[id]</h3>
<span class="scr-id">Issue #752 · LR-2</span>
</div>
<p class="scr-desc">Wenn <code>type === 'JOURNEY'</code> ersetzt die geordnete Briefliste den Prosa-Body. Optional zeigt ein Einleitungsabsatz (<code>body</code>) vor den Items. Jedes Item ist entweder ein Briefeintrag (Kartentitel, Datum, Link) oder ein Interlude-Absatz (orangener linker Rand, kursiv). Die Reihenfolge ergibt sich von oben nach unten — keine Nummern. Briefeinträge können eine optionale Kuratoren-Annotation unter dem Link zeigen.</p>
<p class="scr-var"><strong>Varianten:</strong> Leserin ohne Schreibrecht · BLOG_WRITER (Bearbeiten/Löschen sichtbar — hier gezeigt) · Mobile</p>
<div class="previews">
<!-- Desktop -->
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · BLOG_WRITER-Ansicht</span>
<div class="desk" style="min-height:600px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:16px 20px;">
<div class="jr-article">
<div class="jr-back">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Zurück zu Geschichten
</div>
<div class="jr-badge">LESEREISE</div>
<div class="jr-title">Briefe aus Breslau 19381942</div>
<div class="jr-metabar">
<div class="g-av av-purple" style="width:20px;height:20px;font-size:6.5px;">KR</div>
<div>
<div style="font-size:7.5px;font-weight:700;color:#1C1C18;line-height:1.2;">Klaus Raddatz</div>
<div style="font-size:6.5px;color:#6B6A63;">zusammengestellt am 15. Mai 2025</div>
</div>
<div class="jr-metabar-r">
<button class="jr-edit-btn">Bearbeiten</button>
<span style="font-size:6.5px;font-weight:600;color:#DC4C3E;">Löschen</span>
</div>
</div>
<!-- Intro -->
<div class="jr-intro">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten unbeschwerten Sommerwochen 1938 bis zum Kriegsende. Diese Lesereise folgt den Briefen in chronologischer Reihenfolge.</div>
<!-- Item 1: Document, no annotation -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Brief vom 12. Juli 1938</div>
<div class="jr-card-meta">12. Juli 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
<!-- Interlude -->
<div class="jr-interlude">
<div class="jr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde. Seine Briefe aus dieser Zeit tragen eine Leichtigkeit, die in den späteren Kriegsjahren vollständig verschwindet.</div>
</div>
<!-- Item 2: Document with annotation -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Postkarte aus Breslau, August 1938</div>
<div class="jr-card-meta">22. Aug. 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="jr-annotation">
<div class="jr-annotation-text">Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten, oder schlicht die Hitze des Augusts?</div>
</div>
</div>
</div>
<!-- Item 3: Document -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Brief vom 3. September 1939</div>
<div class="jr-card-meta">3. Sept. 1939 &middot; von Emma Müller an Franz Raddatz</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile -->
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px · Leserin</span>
<div class="phone" style="min-height:520px;">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">MR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:10px;">
<div class="mjr-article">
<div class="mjr-back">
<svg width="6" height="6" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
Zurück
</div>
<div class="mjr-badge">LESEREISE</div>
<div class="mjr-title">Briefe aus Breslau 19381942</div>
<div class="mjr-metabar">
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;flex-shrink:0;">KR</div>
<div>
<div style="font-size:7px;font-weight:700;color:#1C1C18;">Klaus Raddatz</div>
<div style="font-size:6px;color:#6B6A63;">15. Mai 2025</div>
</div>
<div style="margin-left:auto;font-size:12px;color:#6B6A63;">···</div>
</div>
<div style="height:1px;background:#EDECEA;margin-bottom:8px;"></div>
<div class="mjr-intro">Der Briefwechsel zwischen Franz und Emma — von 1938 bis Kriegsende.</div>
<!-- Item 1 -->
<div class="mjr-item">
<div class="mjr-card">
<div class="mjr-card-title">Brief vom 12. Juli 1938</div>
<div class="mjr-card-meta">12. Juli 1938 · Franz → Emma</div>
<div class="mjr-card-link">Brief öffnen →</div>
</div>
</div>
<!-- Interlude -->
<div class="mjr-interlude">
<div class="mjr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</div>
</div>
<!-- Item 2 -->
<div class="mjr-item">
<div class="mjr-card">
<div class="mjr-card-title">Postkarte Aug. 1938</div>
<div class="mjr-card-meta">22. Aug. 1938 · Franz → Emma</div>
<div class="mjr-card-link">Brief öffnen →</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-2 Journey-Leseansicht</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr>
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr>
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide — Lesereisen Reader</h2>
<h3>Geänderte Views und Routen</h3>
<table>
<thead><tr><th>View</th><th>Route</th><th>Änderung</th></tr></thead>
<tbody>
<tr><td>Neue Geschichte</td><td>/geschichten/new</td><td>Neuer Typauswahl-Schritt als first render; setzt ?type=STORY|JOURNEY</td></tr>
<tr><td>Geschichten-Liste</td><td>/geschichten</td><td>Journey-Badge in GeschichtenCard wenn type === 'JOURNEY'</td></tr>
<tr><td>Geschichte-Detail</td><td>/geschichten/[id]</td><td>Bedingte Verzweigung: JourneyReader | StoryReader</td></tr>
</tbody>
</table>
<h3>Neue Komponenten</h3>
<ul>
<li><code>JourneyReader.svelte</code> — rendert Intro + Items-Liste; Props: <code>geschichte: GeschichteDetail</code></li>
<li><code>JourneyItemCard.svelte</code> — ein Dokument-Item mit optionaler Annotation; Props: <code>item: JourneyItem, position: number</code></li>
<li><code>JourneyInterlude.svelte</code> — ein reiner Text-Interlude; Props: <code>note: string</code></li>
</ul>
<h3>Datenmodell (nach #750)</h3>
<ul>
<li><code>GeschichteType: 'STORY' | 'JOURNEY'</code></li>
<li><code>JourneyItem: { id: UUID, position: number, document: DocumentSummary | null, note: string | null }</code></li>
<li><code>Geschichte.items</code> — geordnete Liste (nach <code>position</code> ASC); für STORY leer</li>
<li><code>Geschichte.body</code> — für JOURNEY der optionale Einleitungstext (plaintext, kein HTML); für STORY der Rich-Text-Body</li>
</ul>
<h3>Typauswahl — Implementierungshinweise</h3>
<ul>
<li>Die Typauswahl ist ein Schritt INNERHALB der <code>/geschichten/new</code>-Route — kein eigener URL, kein <code>goto()</code>. Zustand <code>let selectedType: GeschichteType | null = null</code> in der Komponente.</li>
<li>Erst wenn <code>selectedType !== null</code> ist der „Weiter"-Button aktiviert (<code>disabled={!selectedType}</code>).</li>
<li>Nach Klick auf „Weiter": wenn <code>selectedType === 'JOURNEY'</code><code>goto('/geschichten/new?type=JOURNEY')</code> und zeige den Journey-Editor (aus Issue #753); wenn <code>STORY</code> → bestehender GeschichteEditor (unverändert).</li>
<li>Die Karten verwenden <code>role="radio"</code> und <code>aria-checked</code> für Accessibility. Keyboard: Arrow-Keys wechseln zwischen den Karten, Space/Enter wählt aus.</li>
</ul>
<h3>Journey-Badge — Implementierungshinweise</h3>
<ul>
<li>Badge nur in <code>GeschichtenCard.svelte</code> hinzufügen — keine Änderung an der Listenlogik oder dem API-Aufruf.</li>
<li>Text: „REISE" (Kurzform für die Metaspalte); <code>aria-label="Lesereise"</code> für den Badge-Span.</li>
</ul>
<h3>Journey-Reader — Implementierungshinweise</h3>
<ul>
<li>Items werden bereits geordnet vom Backend geliefert (<code>ORDER BY position ASC</code>). Keine client-seitige Sortierung nötig.</li>
<li>Ein Item ist Interlude wenn <code>item.document === null</code>. In diesem Fall: <code>JourneyInterlude</code>-Komponente rendern.</li>
<li>Der Intro-Absatz (<code>body</code>) wird als Plaintext gerendert — <em>nicht</em> als innerHTML. Im Editor wird es als einfaches Textarea gespeichert, kein HTML.</li>
</ul>
<h3>Berechtigungen</h3>
<ul>
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li>
</ul>
<h3>Barrierefreiheit</h3>
<ul>
<li>Items-Liste: <code>&lt;ol&gt;</code> semantisch für die geordnete Briefliste. Interludes sind <code>&lt;li&gt;</code>-Elemente mit <code>aria-label="Kuratorennotiz"</code>.</li>
<li>„Brief öffnen"-Link: beschreibender Text mit Briefdatum im <code>aria-label</code>, z.B. <code>aria-label="Brief vom 12. Juli 1938 öffnen"</code>.</li>
<li>Touch-Targets: jede Dokumentkarte hat mindestens 44px Höhe durch den Padding der Karte.</li>
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Links.</li>
</ul>
</div>
</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

@@ -0,0 +1,188 @@
# spaCy NLP Service — Design Spec
**Date:** 2026-06-07
**Status:** Prototype
## Problem
The current NL search uses Ollama (`qwen2.5:7b-instruct-q4_K_M`) to parse free-text queries into structured extractions (person names, dates, role, keywords). Inference takes 515 seconds per query, making the feature too slow to be useful compared to filling in the filter UI manually.
## Goal
Build a standalone `nlp-service/` prototype that replaces Ollama with spaCy for query parsing. The prototype is scoped to **extraction quality evaluation** — run it locally, curl it with real archive queries, and measure whether spaCy extracts names/dates/keywords well enough to justify a full migration. No Java-side changes in this iteration.
## Extraction Contract
The service must produce an output compatible with the existing `OllamaExtraction` Java record:
| Field | Type | Description |
|---|---|---|
| `personNames` | `string[]` | Names of persons mentioned, left-to-right order |
| `personRole` | `"sender"` \| `"receiver"` \| `"any"` | Role of the person(s) in the document |
| `dateFrom` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
| `dateTo` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
| `keywords` | `string[]` | Content words — fuzzy-matched against tags by Java |
| `rawQuery` | `string` | Echo of the input query |
**Two-person ordering:** `personNames` must be in left-to-right span order. Java maps `[0]` → sender, `[1]` → receiver.
**`rawQuery` note:** In the current Java code `rawQuery` is set by the caller, not parsed from Ollama. The service echoes the input for convenience; the eventual `RestClientSpacyClient` will set it from the input directly, same as today.
## Architecture
```
nlp-service/
├── main.py # FastAPI app — /parse and /health endpoints
├── extractor.py # NLP pipeline: NER → role → dates → keywords
├── models.py # Pydantic request/response types
├── requirements.txt
├── Dockerfile
└── CLAUDE.md
```
Sits alongside `ocr-service/` in the repo. For the prototype it runs standalone (no docker-compose wiring).
## Extraction Pipeline (`extractor.py`)
Five steps run in sequence on each query.
### Step 1 — NER pass
Run spaCy on the query using the model for the requested language. Collect:
- All `PER` spans → candidates for `personNames`
- All `DATE` spans → raw text strings for step 3
### Step 2 — Role detection
Only relevant when exactly **one** PER entity is found. Walk the dependency tree of the PER span's root token; check if a governing `case` or `prep` token matches the sender or receiver preposition set for the language:
| Language | Sender prepositions | Receiver prepositions |
|---|---|---|
| `de` | von, vom | an, nach, für |
| `en` | from, by | to, for |
| `es` | de, por | para, a |
- One person + sender preposition → `personRole = "sender"`
- One person + receiver preposition → `personRole = "receiver"`
- One person + no match / two or more persons → `personRole = "any"`
Two-person queries always return `"any"` — Java derives direction from position.
### Step 3 — Date parsing
For each DATE span, inspect the token immediately before the span to detect range direction:
| Direction token | Effect |
|---|---|
| vor / before / antes de | Span → `dateTo` |
| nach / after / después de | Span → `dateFrom` |
| zwischen…und / between…and / entre…y | Earlier span → `dateFrom`, later → `dateTo` |
| No direction token (bare year/date) | Span → both `dateFrom` and `dateTo` set to that year (year-range, Jan 1Dec 31) |
`dateparser.parse()` with `PREFER_DAY_OF_MONTH=first` converts the span text to a Python `date`. For `dateTo` results that resolve to a year boundary, set to Dec 31 of that year (mirrors `RestClientOllamaClient.parseDate()` behaviour).
Output as ISO strings (`YYYY-MM-DD`) or `null`.
### Step 4 — Keyword extraction
Collect tokens that satisfy all of:
- POS tag is `NOUN` or `PROPN`
- Not a stopword
- Not inside any NER span (PER or DATE)
- Lemma length ≥ 3
Output as lowercased lemmas. These are fuzzy-matched against the tags table by `NlQueryParserService.resolveTags()` on the Java side — no tag lookup in the Python service.
Examples:
- "Briefe aus dem Krieg" → `keywords: ["brief", "krieg"]`
- "Texte über Weihnachten" → `keywords: ["text", "weihnachten"]`
### Step 5 — Assembly
```json
{
"personNames": ["Opa Hermann", "Marie"],
"personRole": "any",
"dateFrom": null,
"dateTo": "1920-12-31",
"keywords": ["brief"],
"rawQuery": "Briefe von Opa Hermann an Marie vor 1920"
}
```
## API
### `POST /parse`
**Request:**
```json
{ "query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de" }
```
`lang` is a required enum: `"de"` | `"en"` | `"es"`. Unknown values → HTTP 422 (FastAPI validation).
**Response:** extraction object as above, HTTP 200.
**Error:** pipeline crash → HTTP 500 `{"detail": "..."}`.
### `GET /health`
Returns HTTP 200 `{"status": "ok"}` when all three models are loaded.
## Language Models
| `lang` | spaCy model |
|---|---|
| `de` | `de_core_news_sm` |
| `en` | `en_core_web_sm` |
| `es` | `es_core_news_sm` |
All three models are loaded at startup and held in memory. Routing is by the `lang` field on the request.
## Dockerfile
Mirrors `ocr-service/``python:3.11-slim`, non-root user, models baked into the image:
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN python -m spacy download de_core_news_sm \
&& python -m spacy download en_core_web_sm \
&& python -m spacy download es_core_news_sm
COPY . .
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1001 nlp \
&& chown -R nlp:nlp /app
USER nlp
EXPOSE 8001
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
```
Image size: ~350 MB. No volume needed — models live in the image layer.
## Local Dev
```bash
cd nlp-service
pip install -r requirements.txt
python -m spacy download de_core_news_sm en_core_web_sm es_core_news_sm
uvicorn main:app --reload --port 8001
curl -X POST http://localhost:8001/parse \
-H "Content-Type: application/json" \
-d '{"query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de"}'
```
## Known Limitations
- **Historical names:** spaCy models are trained on modern news corpora. Unusual 18991950 German names may not score as `PER`. Mitigation: the Java `resolveNames()` already does fuzzy matching against the persons table, so partial name extraction is recoverable.
- **Role detection:** the preposition sets are a fixed enumeration (~12 tokens across 3 languages). Sentences that express direction without one of these prepositions will fall through to `personRole = "any"`. This is acceptable — `"any"` is the safe default and searches both sender and receiver positions.
- **"über Oma" ambiguity:** if spaCy recognises "Oma" as a PER entity it lands in `personNames` (person search); if not, it lands in `keywords` (tag search via Java). Both paths return relevant results. The prototype evaluation will reveal which path dominates for real archive queries.
## Out of Scope (prototype)
- docker-compose integration (Ollama replacement)
- Java-side changes (`RestClientSpacyClient`, rename `OllamaClient``NlParserClient`)
- Tag lookup inside the Python service
- Automated test suite (pytest fixtures) — evaluation is done by curling the running service

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Namen tippen...",
"comp_multiselect_remove": "Entfernen",
"comp_multiselect_loading": "Suche...",
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
"comp_typeahead_no_results": "Keine Treffer",
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen",
@@ -1023,6 +1025,11 @@
"nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.",
"error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.",
"error_geschichte_type_mismatch": "Diese Geschichte ist keine Lesereise Reise-Einträge sind hier nicht erlaubt.",
"journey_item_document_deleted": "[Dokument gelöscht]",
"geschichten_index_title": "Geschichten",
"geschichten_new_button": "Neue Geschichte",
"geschichten_filter_all_pill": "Alle",
@@ -1037,6 +1044,7 @@
"geschichten_published_on": "veröffentlicht am {date}",
"geschichten_persons_section": "Personen in dieser Geschichte",
"geschichten_documents_section": "Erwähnte Dokumente",
"geschichten_document_link_placeholder": "Dokument öffnen",
"geschichten_card_heading": "Geschichten",
"geschichten_card_write_action": "+ Geschichte schreiben",
"geschichten_card_attach_action": "+ Geschichte anhängen",
@@ -1044,6 +1052,7 @@
"geschichten_card_show_all": "Alle anzeigen",
"geschichte_editor_title_placeholder": "Titel der Geschichte",
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
"geschichte_sidebar_status": "Status",
"geschichte_editor_status_draft": "ENTWURF",
"geschichte_editor_status_published": "VERÖFFENTLICHT",
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
@@ -1153,5 +1162,54 @@
"themen_alle": "Alle Themen",
"themen_leer": "Noch keine Themen vergeben.",
"themen_weitere": "+ {count} weitere",
"themen_dokumente": "{count} Dokumente"
"themen_dokumente": "{count} Dokumente",
"journey_badge_list": "REISE",
"journey_badge_detail": "LESEREISE",
"journey_selector_question": "Was möchtest du erstellen?",
"journey_selector_story_title": "Geschichte",
"journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.",
"journey_selector_journey_title": "Lesereise",
"journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
"journey_selector_next_btn": "Weiter",
"journey_placeholder_back": "andere Auswahl",
"journey_create_submit": "Lesereise erstellen",
"journey_item_open_aria": "Brief vom {date} öffnen",
"journey_item_open_aria_undated": "Brief öffnen",
"journey_empty_state": "Diese Lesereise ist noch leer.",
"journey_interlude_aria_label": "Kuratorennotiz",
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren.",
"journey_add_document": "Brief hinzufügen",
"journey_add_interlude": "Zwischentext hinzufügen",
"journey_interlude_label": "Zwischentext",
"journey_item_pending_remove": "wird entfernt…",
"journey_publish_disabled_hint": "Titel und mindestens ein Eintrag erforderlich.",
"journey_title_aria_label": "Titel der Lesereise",
"journey_intro_aria_label": "Einleitung der Lesereise",
"journey_note_add": "Notiz hinzufügen",
"journey_note_remove": "Notiz entfernen",
"journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.",
"journey_intro_save_hint": "Wird mit 'Speichern' gesichert.",
"journey_already_added": "Bereits enthalten",
"journey_note_aria_label": "Kuratoren-Notiz für {title}",
"journey_drag_aria_label": "Reihenfolge von '{title}' ändern",
"journey_move_up": "'{title}' nach oben verschieben",
"journey_move_down": "'{title}' nach unten verschieben",
"journey_note_error": "Notiz konnte nicht gespeichert werden",
"journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben",
"journey_remove_item_aria": "Eintrag entfernen",
"journey_remove_confirm": "Wirklich entfernen?",
"journey_remove_confirm_yes": "Bestätigen",
"journey_remove_confirm_cancel": "Abbrechen",
"journey_mutation_error_reload": "Aktion fehlgeschlagen bitte Seite neu laden.",
"journey_published_empty_warning": "Diese Reise wird ohne Einträge veröffentlicht bleiben.",
"journey_intro_placeholder": "Einleitung (optional)",
"journey_interlude_placeholder": "Zwischentext eingeben…",
"journey_add_interlude_confirm": "Hinzufügen",
"journey_edit_title_story": "Geschichte bearbeiten",
"journey_edit_title_journey": "Lesereise bearbeiten",
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
}

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Type a name...",
"comp_multiselect_remove": "Remove",
"comp_multiselect_loading": "Searching...",
"comp_typeahead_error": "Search failed. Please try again.",
"comp_typeahead_no_results": "No matches",
"comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag",
@@ -1023,6 +1025,11 @@
"nav_stammbaum": "Family tree",
"nav_geschichten": "Stories",
"error_geschichte_not_found": "The story was not found.",
"error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
"error_journey_at_capacity": "The reading journey has already reached the maximum of 100 items.",
"error_geschichte_type_mismatch": "This story is not a reading journey — journey items are not allowed here.",
"journey_item_document_deleted": "[Document deleted]",
"geschichten_index_title": "Stories",
"geschichten_new_button": "New story",
"geschichten_filter_all_pill": "All",
@@ -1037,6 +1044,7 @@
"geschichten_published_on": "published on {date}",
"geschichten_persons_section": "People in this story",
"geschichten_documents_section": "Referenced documents",
"geschichten_document_link_placeholder": "Open document",
"geschichten_card_heading": "Stories",
"geschichten_card_write_action": "+ Write a story",
"geschichten_card_attach_action": "+ Attach a story",
@@ -1044,6 +1052,7 @@
"geschichten_card_show_all": "Show all",
"geschichte_editor_title_placeholder": "Story title",
"geschichte_editor_body_placeholder": "Write your story here…",
"geschichte_sidebar_status": "Status",
"geschichte_editor_status_draft": "DRAFT",
"geschichte_editor_status_published": "PUBLISHED",
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
@@ -1153,5 +1162,54 @@
"themen_alle": "All Topics",
"themen_leer": "No topics assigned yet.",
"themen_weitere": "+ {count} more",
"themen_dokumente": "{count} documents"
"themen_dokumente": "{count} documents",
"journey_badge_list": "JOURNEY",
"journey_badge_detail": "READING JOURNEY",
"journey_selector_question": "What would you like to create?",
"journey_selector_story_title": "Story",
"journey_selector_story_desc": "A narrative story with images and text.",
"journey_selector_journey_title": "Reading Journey",
"journey_selector_journey_desc": "A curated selection of letters with notes.",
"journey_selector_next_btn": "Continue",
"journey_placeholder_back": "different selection",
"journey_create_submit": "Create reading journey",
"journey_item_open_aria": "Open letter from {date}",
"journey_item_open_aria_undated": "Open letter",
"journey_empty_state": "This reading journey is still empty.",
"journey_interlude_aria_label": "Curator's note",
"journey_selector_aria_live_hint": "Please select a type to continue.",
"journey_add_document": "Add letter",
"journey_add_interlude": "Add interlude",
"journey_interlude_label": "Interlude",
"journey_item_pending_remove": "removing…",
"journey_publish_disabled_hint": "A title and at least one entry are required.",
"journey_title_aria_label": "Title of the reading journey",
"journey_intro_aria_label": "Introduction of the reading journey",
"journey_note_add": "Add note",
"journey_note_remove": "Remove note",
"journey_note_save_hint": "Saved when you leave the field.",
"journey_intro_save_hint": "Saved when you click 'Save'.",
"journey_already_added": "Already included",
"journey_note_aria_label": "Curator note for {title}",
"journey_drag_aria_label": "Change order of '{title}'",
"journey_move_up": "Move '{title}' up",
"journey_move_down": "Move '{title}' down",
"journey_note_error": "Could not save note",
"journey_item_moved": "Entry {position} of {total} — moved to position {newPosition}",
"journey_remove_item_aria": "Remove item",
"journey_remove_confirm": "Really remove?",
"journey_remove_confirm_yes": "Confirm",
"journey_remove_confirm_cancel": "Cancel",
"journey_mutation_error_reload": "Action failed please reload the page.",
"journey_published_empty_warning": "This journey will remain published without any entries.",
"journey_intro_placeholder": "Introduction (optional)",
"journey_interlude_placeholder": "Enter interlude text…",
"journey_add_interlude_confirm": "Add",
"journey_edit_title_story": "Edit story",
"journey_edit_title_journey": "Edit reading journey",
"journey_publish_disabled_title": "Title and at least one entry required",
"journey_save_hint_published": "Changes will be immediately visible to all readers.",
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
"error_journey_document_already_added": "This letter is already included in the reading journey.",
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
}

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Escriba un nombre...",
"comp_multiselect_remove": "Eliminar",
"comp_multiselect_loading": "Buscando...",
"comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
"comp_typeahead_no_results": "Sin resultados",
"comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta",
@@ -1023,6 +1025,11 @@
"nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias",
"error_geschichte_not_found": "No se encontró la historia.",
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
"error_journey_at_capacity": "El viaje de lectura ya ha alcanzado el máximo de 100 entradas.",
"error_geschichte_type_mismatch": "Esta historia no es un viaje de lectura — los elementos de viaje no están permitidos aquí.",
"journey_item_document_deleted": "[Documento eliminado]",
"geschichten_index_title": "Historias",
"geschichten_new_button": "Nueva historia",
"geschichten_filter_all_pill": "Todas",
@@ -1037,6 +1044,7 @@
"geschichten_published_on": "publicada el {date}",
"geschichten_persons_section": "Personas en esta historia",
"geschichten_documents_section": "Documentos mencionados",
"geschichten_document_link_placeholder": "Abrir documento",
"geschichten_card_heading": "Historias",
"geschichten_card_write_action": "+ Escribir historia",
"geschichten_card_attach_action": "+ Adjuntar historia",
@@ -1044,6 +1052,7 @@
"geschichten_card_show_all": "Mostrar todas",
"geschichte_editor_title_placeholder": "Título de la historia",
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
"geschichte_sidebar_status": "Estado",
"geschichte_editor_status_draft": "BORRADOR",
"geschichte_editor_status_published": "PUBLICADA",
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
@@ -1153,5 +1162,54 @@
"themen_alle": "Todos los temas",
"themen_leer": "Aún no hay temas.",
"themen_weitere": "+ {count} más",
"themen_dokumente": "{count} documentos"
"themen_dokumente": "{count} documentos",
"journey_badge_list": "VIAJE",
"journey_badge_detail": "VIAJE DE LECTURA",
"journey_selector_question": "¿Qué deseas crear?",
"journey_selector_story_title": "Historia",
"journey_selector_story_desc": "Una historia narrada con imágenes y texto.",
"journey_selector_journey_title": "Viaje de lectura",
"journey_selector_journey_desc": "Una selección curada de cartas con notas.",
"journey_selector_next_btn": "Continuar",
"journey_placeholder_back": "otra selección",
"journey_create_submit": "Crear viaje de lectura",
"journey_item_open_aria": "Abrir carta del {date}",
"journey_item_open_aria_undated": "Abrir carta",
"journey_empty_state": "Este viaje de lectura está vacío.",
"journey_interlude_aria_label": "Nota del curador",
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar.",
"journey_add_document": "Añadir carta",
"journey_add_interlude": "Añadir interludio",
"journey_interlude_label": "Interludio",
"journey_item_pending_remove": "eliminando…",
"journey_publish_disabled_hint": "Se requieren un título y al menos una entrada.",
"journey_title_aria_label": "Título del viaje de lectura",
"journey_intro_aria_label": "Introducción del viaje de lectura",
"journey_note_add": "Añadir nota",
"journey_note_remove": "Eliminar nota",
"journey_note_save_hint": "Se guarda al salir del campo.",
"journey_intro_save_hint": "Se guarda al hacer clic en 'Guardar'.",
"journey_already_added": "Ya incluido",
"journey_note_aria_label": "Nota del curador para {title}",
"journey_drag_aria_label": "Cambiar el orden de '{title}'",
"journey_move_up": "Subir '{title}'",
"journey_move_down": "Bajar '{title}'",
"journey_note_error": "No se pudo guardar la nota",
"journey_item_moved": "Entrada {position} de {total} — movida a la posición {newPosition}",
"journey_remove_item_aria": "Eliminar entrada",
"journey_remove_confirm": "¿Realmente eliminar?",
"journey_remove_confirm_yes": "Confirmar",
"journey_remove_confirm_cancel": "Cancelar",
"journey_mutation_error_reload": "Acción fallida por favor recarga la página.",
"journey_published_empty_warning": "Este viaje permanecerá publicado sin entradas.",
"journey_intro_placeholder": "Introducción (opcional)",
"journey_interlude_placeholder": "Escribe el texto del interludio…",
"journey_add_interlude_confirm": "Añadir",
"journey_edit_title_story": "Editar historia",
"journey_edit_title_journey": "Editar viaje de lectura",
"journey_publish_disabled_title": "Se requiere título y al menos una entrada",
"journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.",
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
}

View File

@@ -52,6 +52,6 @@ describe('DashboardNeedsMetadata', () => {
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
const docs = [makeDoc('d1', 'Only one')];
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /Alle 50/ })).toBeInTheDocument();
});
});

View File

@@ -1,21 +1,11 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
type DocumentListItem = components['schemas']['DocumentListItem'];
/**
* Exactly the fields this picker reads — id for selection/dedup, the rest for
* the honest date label. A full `Document` and a `DocumentListItem` are both
* structurally assignable, so the search results need no cast.
*/
type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
import {
createDocumentTypeahead,
formatDocumentOption,
type DocumentOption
} from './documentTypeahead';
interface Props {
selectedDocuments?: DocumentOption[];
@@ -30,13 +20,16 @@ let {
}: Props = $props();
let searchTerm = $state('');
let results: DocumentOption[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
const picker = createDocumentTypeahead();
// Filter out already-selected documents from typeahead results.
const filteredResults = $derived(
picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id))
);
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
@@ -44,57 +37,22 @@ function updateDropdownPosition() {
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentListItem[] } = await res.json();
const docs: DocumentOption[] = body.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate,
metaDatePrecision: it.metaDatePrecision,
metaDateEnd: it.metaDateEnd
}));
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
if (searchTerm.trim().length >= 1) {
picker.setQuery(searchTerm);
} else {
picker.close();
}
}
function selectDocument(doc: DocumentOption) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
showDropdown = false;
results = [];
picker.close();
}
function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
}
function formatDocLabel(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()
);
return `${doc.title} · ${label}`;
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
@@ -103,7 +61,7 @@ function formatDocLabel(doc: DocumentOption): string {
<input type="hidden" name={hiddenInputName} value={doc.id} />
{/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div class="relative" use:clickOutside onclickoutside={() => picker.close()}>
<div
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
>
@@ -111,7 +69,7 @@ function formatDocLabel(doc: DocumentOption): string {
<span
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
>
{formatDocLabel(doc)}
{formatDocumentOption(doc)}
<button
type="button"
onclick={() => removeDocument(doc.id)}
@@ -136,24 +94,23 @@ function formatDocLabel(doc: DocumentOption): string {
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => {
updateDropdownPosition();
showDropdown = true;
}}
onfocus={() => updateDropdownPosition()}
placeholder={placeholder}
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/>
</div>
{#if showDropdown && (results.length > 0 || loading)}
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading || picker.error)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
>
{#if loading}
{#if picker.loading}
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
{:else if picker.error}
<div role="alert" class="p-2 text-sm text-danger">{m.comp_typeahead_error()}</div>
{:else}
{#each results as doc (doc.id)}
{#each filteredResults as doc (doc.id)}
<div
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
onclick={() => selectDocument(doc)}
@@ -161,7 +118,7 @@ function formatDocLabel(doc: DocumentOption): string {
role="button"
tabindex="0"
>
{formatDocLabel(doc)}
{formatDocumentOption(doc)}
</div>
{/each}
{/if}

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -124,6 +125,28 @@ describe('DocumentMultiSelect — search and select', () => {
});
});
describe('DocumentMultiSelect — search failure', () => {
it('shows an error row when the search request fails instead of looking like "no results"', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
const alert = page.getByRole('alert');
await expect.element(alert).toBeInTheDocument();
await expect.element(alert).toHaveTextContent(m.comp_typeahead_error());
});
});
describe('DocumentMultiSelect — remove', () => {
it('removes a chip when its × button is clicked', async () => {
render(DocumentMultiSelect, {

View File

@@ -0,0 +1,144 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import {
createDocumentTypeahead,
formatDocumentOption,
type DocumentOption
} from './documentTypeahead';
interface Props {
alreadyAddedIds?: Set<string>;
placeholder?: string;
onSelect: (doc: DocumentOption) => void;
}
let {
alreadyAddedIds = new Set(),
placeholder = m.journey_add_document(),
onSelect
}: Props = $props();
const uid = $props.id();
const listboxId = `doc-picker-listbox-${uid}`;
const picker = createDocumentTypeahead();
let inputValue = $state('');
const activeOptionId = $derived(
picker.isOpen && picker.activeIndex >= 0 && picker.results[picker.activeIndex]
? `${listboxId}-option-${picker.activeIndex}`
: undefined
);
function handleInput(e: Event) {
const q = (e.currentTarget as HTMLInputElement).value;
inputValue = q;
picker.setActiveIndex(-1);
if (q.trim().length >= 1) {
picker.setQuery(q);
} else {
picker.close();
}
}
function handleSelect(doc: DocumentOption) {
if (alreadyAddedIds.has(doc.id!)) return;
inputValue = '';
picker.close();
onSelect(doc);
}
function handleKeydown(e: KeyboardEvent) {
if (!picker.isOpen) return;
const results = picker.results;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
picker.setActiveIndex((picker.activeIndex + 1) % results.length);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
picker.setActiveIndex((picker.activeIndex - 1 + results.length) % results.length);
}
} else if (e.key === 'Enter') {
e.preventDefault();
const active = results[picker.activeIndex];
// handleSelect is a no-op for already-added (aria-disabled) options.
if (active) handleSelect(active);
} else if (e.key === 'Escape') {
e.preventDefault();
picker.close();
}
}
function handleOptionKeydown(e: KeyboardEvent, doc: DocumentOption) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(doc);
}
}
</script>
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
<input
type="text"
role="combobox"
autocomplete="off"
aria-label={placeholder}
aria-expanded={picker.isOpen}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
placeholder={placeholder}
value={inputValue}
oninput={handleInput}
onkeydown={handleKeydown}
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if picker.isOpen}
<ul
id={listboxId}
role="listbox"
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
{#if picker.loading}
<li class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</li>
{:else if picker.error}
<li role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</li>
{:else if picker.results.length === 0}
<li class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</li>
{:else}
{#each picker.results as doc, i (doc.id)}
{@const disabled = alreadyAddedIds.has(doc.id!)}
<li
id={`${listboxId}-option-${i}`}
role="option"
aria-selected={i === picker.activeIndex}
aria-disabled={disabled}
onclick={() => handleSelect(doc)}
onkeydown={(e) => handleOptionKeydown(e, doc)}
tabindex={disabled ? -1 : 0}
class={[
'px-3 py-2 text-ink select-none',
i === picker.activeIndex ? 'bg-muted' : '',
disabled
? 'cursor-default opacity-50'
: 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none'
].join(' ')}
>
{formatDocumentOption(doc)}
{#if disabled}
<span class="sr-only">{m.journey_already_added()}</span>
{/if}
</li>
{/each}
{/if}
</ul>
{/if}
</div>

View File

@@ -0,0 +1,200 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentPickerDropdown from './DocumentPickerDropdown.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const docFactory = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY' as const,
metaDateEnd: undefined
});
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items })
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('DocumentPickerDropdown — empty query guard', () => {
it('does not call fetch on empty query', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), '');
await waitForDebounce();
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — already-added indicator', () => {
it('shows already-added document as aria-disabled with sr-only hint', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect: vi.fn()
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
const disabledItem = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(disabledItem.getAttribute('aria-disabled')).toBe('true');
// Screen-reader text "bereits enthalten" must be present in the item
await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — selection', () => {
it('calls onSelect with the item when a non-disabled option is clicked', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.click(page.getByText(/Brief von Eugenie/i));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not call onSelect when an aria-disabled option is clicked', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await page.getByText(/Brief von Eugenie/i).click({ force: true });
expect(onSelect).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — keyboard navigation', () => {
it('selects the first option via ArrowDown then Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not select an aria-disabled option on Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).not.toHaveBeenCalled();
});
it('closes the dropdown on Escape', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('points aria-activedescendant at the active option', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox');
await userEvent.fill(input, 'Brief');
await waitForDebounce();
expect(input.element().getAttribute('aria-activedescendant')).toBeNull();
await userEvent.keyboard('{ArrowDown}');
const activeId = input.element().getAttribute('aria-activedescendant');
expect(activeId).toMatch(/-option-0$/);
const firstOption = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(firstOption.id).toBe(activeId);
expect(firstOption.getAttribute('aria-selected')).toBe('true');
});
});
describe('DocumentPickerDropdown — no results', () => {
it('shows a non-interactive no-results row when the search returns zero hits', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — search failure', () => {
it('shows an error message when the search request fails instead of vanishing', async () => {
// 500 from /api/documents/search — must surface, not render as "no results"
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,45 @@
import type { components } from '$lib/generated/api';
import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
type DocumentListItem = components['schemas']['DocumentListItem'];
export type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
export function createDocumentTypeahead() {
return createTypeahead<DocumentOption>({
fetchUrl: (q) =>
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
.then((r) => {
// Without this check a 401/500 parses as JSON without `items` and
// renders as "no results" — errors must reach the hook's error state.
if (!r.ok) throw new Error(`document search failed: ${r.status}`);
return r.json();
})
.then((b: { items: DocumentListItem[] }) =>
b.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate,
metaDatePrecision: it.metaDatePrecision,
metaDateEnd: it.metaDateEnd
}))
)
});
}
export function formatDocumentOption(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()
);
return `${doc.title} · ${label}`;
}

View File

@@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte';
import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte';
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { csrfFetch } from '$lib/shared/cookies';
type Props = {

View File

@@ -84,7 +84,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/confirm": {
"/api/geschichten/{id}/items/reorder": {
parameters: {
query?: never;
header?: never;
@@ -92,12 +92,16 @@ export interface paths {
cookie?: never;
};
get?: never;
put?: never;
/**
* Reorder journey items
* @description itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request.
*/
put: operations["reorderItems"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["confirmPerson"];
patch?: never;
trace?: never;
};
"/api/documents/{id}": {
@@ -436,6 +440,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/geschichten/{id}/items": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["appendItem"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents": {
parameters: {
query?: never;
@@ -708,6 +728,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/backfill-titles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["backfillTitles"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/backfill-file-hashes": {
parameters: {
query?: never;
@@ -740,6 +776,22 @@ export interface paths {
patch: operations["patchFamilyMember"];
trace?: never;
};
"/api/persons/{id}/confirm": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["confirmPerson"];
trace?: never;
};
"/api/notifications/{id}/read": {
parameters: {
query?: never;
@@ -788,6 +840,22 @@ export interface paths {
patch: operations["update"];
trace?: never;
};
"/api/geschichten/{id}/items/{itemId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteItem"];
options?: never;
head?: never;
patch: operations["updateItemNote"];
trace?: never;
};
"/api/documents/{id}/training-labels": {
parameters: {
query?: never;
@@ -1651,7 +1719,7 @@ export interface components {
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
generation?: number;
};
Person: {
/** Format: uuid */
@@ -1668,12 +1736,38 @@ export interface components {
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
generation?: number;
familyMember: boolean;
sourceRef?: string;
provisional: boolean;
readonly displayName: string;
};
JourneyReorderDTO: {
itemIds?: string[];
};
DocumentSummary: {
/** Format: uuid */
id: string;
title: string;
/** Format: date */
documentDate?: string;
/** Format: date */
documentDateEnd?: string;
/** @enum {string} */
datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
senderName?: string;
receiverName?: string;
/** Format: int32 */
receiverCount: number;
};
JourneyItemView: {
/** Format: uuid */
id: string;
/** Format: int32 */
position: number;
document?: components["schemas"]["DocumentSummary"];
note?: string;
};
DocumentUpdateDTO: {
title?: string;
/** Format: date */
@@ -1930,25 +2024,44 @@ export interface components {
body?: string;
/** @enum {string} */
status?: "DRAFT" | "PUBLISHED";
/** @enum {string} */
type?: "STORY" | "JOURNEY";
personIds?: string[];
documentIds?: string[];
};
Geschichte: {
AuthorView: {
/** Format: uuid */
id: string;
displayName: string;
};
GeschichteView: {
/** Format: uuid */
id: string;
title: string;
body?: string;
/** @enum {string} */
status: "DRAFT" | "PUBLISHED";
author?: components["schemas"]["AppUser"];
persons?: components["schemas"]["Person"][];
documents?: components["schemas"]["Document"][];
/** @enum {string} */
type: "STORY" | "JOURNEY";
author?: components["schemas"]["AuthorView"];
persons: components["schemas"]["PersonView"][];
items: components["schemas"]["JourneyItemView"][];
/** Format: date-time */
publishedAt?: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
/** Format: date-time */
publishedAt?: string;
};
PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
JourneyItemCreateDTO: {
/** Format: uuid */
documentId?: string;
note?: string;
};
CreateTranscriptionBlockDTO: {
/** Format: int32 */
@@ -2148,6 +2261,9 @@ export interface components {
actorName?: string;
documentTitle?: string;
};
JourneyItemUpdateDTO: {
note?: string;
};
TrainingLabelRequest: {
label?: string;
enrolled?: boolean;
@@ -2235,25 +2351,6 @@ export interface components {
/** Format: int64 */
totalStories: number;
};
PersonSummaryDTO: {
title?: string;
/** Format: uuid */
id?: string;
displayName?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
documentCount?: number;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
alias?: string;
notes?: string;
personType?: string;
familyMember?: boolean;
provisional?: boolean;
};
PersonSearchResult: {
items: components["schemas"]["PersonSummaryDTO"][];
/** Format: int64 */
@@ -2265,6 +2362,25 @@ export interface components {
/** Format: int32 */
totalPages: number;
};
PersonSummaryDTO: {
title?: string;
/** Format: uuid */
id?: string;
displayName?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
documentCount?: number;
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
provisional?: boolean;
personType?: string;
familyMember?: boolean;
};
InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"];
label: string;
@@ -2280,7 +2396,7 @@ export interface components {
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
generation?: number;
familyMember: boolean;
};
InferredRelationshipDTO: {
@@ -2360,6 +2476,8 @@ export interface components {
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -2368,8 +2486,6 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -2392,6 +2508,25 @@ export interface components {
nodes: components["schemas"]["PersonNodeDTO"][];
edges: components["schemas"]["RelationshipDTO"][];
};
AuthorSummary: {
firstName?: string;
lastName?: string;
};
GeschichteSummary: {
body?: string;
title: string;
/** Format: uuid */
id: string;
/** @enum {string} */
type: "STORY" | "JOURNEY";
/** @enum {string} */
status: "DRAFT" | "PUBLISHED";
/** Format: date-time */
updatedAt: string;
author?: components["schemas"]["AuthorSummary"];
/** Format: date-time */
publishedAt?: string;
};
DocumentVersionSummary: {
/** Format: uuid */
id: string;
@@ -2538,7 +2673,7 @@ export interface components {
};
ActivityFeedItemDTO: {
/** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
@@ -2828,6 +2963,52 @@ export interface operations {
};
};
};
deletePerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
reorderItems: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["JourneyReorderDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["JourneyItemView"][];
};
};
};
};
getDocument: {
parameters: {
query?: never;
@@ -3184,48 +3365,6 @@ export interface operations {
};
};
};
confirmPerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
deletePerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
createPerson: {
parameters: {
query?: never;
@@ -3590,7 +3729,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"][];
"*/*": components["schemas"]["GeschichteSummary"][];
};
};
};
@@ -3614,7 +3753,33 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
"*/*": components["schemas"]["GeschichteView"];
};
};
};
};
appendItem: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["JourneyItemCreateDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["JourneyItemView"];
};
};
};
@@ -4117,6 +4282,26 @@ export interface operations {
};
};
};
backfillTitles: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BackfillResult"];
};
};
};
};
backfillFileHashes: {
parameters: {
query?: never;
@@ -4163,6 +4348,28 @@ export interface operations {
};
};
};
confirmPerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
markOneRead: {
parameters: {
query?: never;
@@ -4248,7 +4455,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
"*/*": components["schemas"]["GeschichteView"];
};
};
};
@@ -4294,7 +4501,55 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
"*/*": components["schemas"]["GeschichteView"];
};
};
};
};
deleteItem: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
itemId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateItemNote: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
itemId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["JourneyItemUpdateDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["JourneyItemView"];
};
};
};
@@ -5282,7 +5537,7 @@ export interface operations {
query?: {
limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[];
};
header?: never;
path?: never;

View File

@@ -5,34 +5,25 @@ import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
import GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte';
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteView = components['schemas']['GeschichteView'];
type Person = components['schemas']['Person'];
type Document = components['schemas']['Document'];
interface Props {
geschichte?: Geschichte | null;
geschichte?: GeschichteView | null;
initialPersons?: Person[];
initialDocuments?: Document[];
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let {
geschichte = null,
initialPersons = [],
initialDocuments = [],
onSubmit,
submitting = false
}: Props = $props();
let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Props = $props();
// Initial-state snapshot from incoming props. The editor owns these values
// after mount; the parent should re-mount the component with a different
@@ -41,11 +32,8 @@ let {
let title = $state(geschichte?.title ?? '');
let body = $state(geschichte?.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
let selectedPersons: Person[] = $state(
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
);
let selectedDocuments: Document[] = $state(
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
let selectedPersons: PersonOption[] = $state(
geschichte?.persons ? Array.from(geschichte.persons).map(toPersonOption) : initialPersons
);
let dirty = $state(false);
@@ -122,8 +110,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
});
dirty = false;
}
@@ -241,43 +228,7 @@ function exec(action: () => void) {
</div>
<!-- Sidebar -->
<aside class="flex flex-col gap-6">
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
<p class="mb-3">
<span
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
? 'bg-muted text-ink-2'
: 'bg-accent-bg text-ink'}"
>
{isDraft
? m.geschichte_editor_status_draft()
: m.geschichte_editor_status_published()}
</span>
</p>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_status_draft_hint()
: m.geschichte_editor_status_published_hint()}
</p>
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_personen_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_dokumente_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
</section>
</aside>
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
</div>
<!-- Save bar -->

View File

@@ -8,19 +8,9 @@ const personFactory = (id: string, displayName: string) => ({
firstName: displayName.split(' ')[0],
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
displayName,
personType: 'PERSON' as const
});
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED' as const,
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
personType: 'PERSON' as const,
familyMember: false,
provisional: false
});
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
@@ -28,8 +18,9 @@ const draftFactory = (overrides: Record<string, unknown> = {}) => ({
title: 'Existing draft',
body: '<p>Hello world</p>',
status: 'DRAFT' as const,
type: 'STORY' as const,
persons: [],
documents: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
@@ -93,14 +84,6 @@ describe('GeschichteEditor — pre-fill', () => {
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
});
it('renders initial documents as chips', async () => {
render(GeschichteEditor, {
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
onSubmit: vi.fn()
});
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('populates the title input from a geschichte prop', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ title: 'My existing story' }),
@@ -154,11 +137,10 @@ describe('GeschichteEditor — onSubmit payload', () => {
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
});
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
it('passes personIds from initial props through onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
initialDocuments: [docFactory('d1', 'Brief A')],
onSubmit
});
@@ -171,6 +153,5 @@ describe('GeschichteEditor — onSubmit payload', () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.personIds).toEqual(['p1']);
expect(payload.documentIds).toEqual(['d1']);
});
});

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatAuthorName, formatPublishedAt } from './utils';
import type { components } from '$lib/generated/api';
type GeschichteRow = Pick<
components['schemas']['GeschichteSummary'],
'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'
>;
let { geschichte }: { geschichte: GeschichteRow } = $props();
const isJourney = $derived(geschichte.type === 'JOURNEY');
const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'));
const authorName = $derived(formatAuthorName(geschichte.author));
</script>
<a href="/geschichten/{geschichte.id}" class="block">
<div class="mb-1 flex items-center gap-1.5">
<h2 class="font-serif text-xl font-bold text-ink">{geschichte.title}</h2>
{#if isJourney}
<span
data-testid="journey-badge"
style="font-size: 0.75rem"
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
>
{m.journey_badge_list()}
</span>
{/if}
</div>
<p class="mb-3 font-sans text-xs text-ink-3">
{authorName}
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
</p>
{#if geschichte.body}
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
<p class="font-serif text-base text-ink-2">{plainExcerpt(geschichte.body, 150)}</p>
{/if}
</a>

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte');
afterEach(cleanup);
const baseRow = (overrides = {}) => ({
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
type: 'STORY' as 'STORY' | 'JOURNEY',
status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT',
author: { firstName: 'Anna', lastName: 'Schmidt' },
publishedAt: '2026-04-15T10:00:00Z',
...overrides
});
describe('GeschichteListRow', () => {
it('renders the title', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
await expect
.element(page.getByRole('heading', { level: 2 }))
.toHaveTextContent('Die Reise nach Berlin');
});
it('shows no badge for STORY type', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } });
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
});
it('shows no badge when type is undefined', async () => {
render(GeschichteListRow, {
props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) }
});
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
});
it('shows REISE badge for JOURNEY type', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]');
expect(badge).not.toBeNull();
expect(badge?.textContent?.trim()).toBe('REISE');
});
it('badge is a plain <span>, not a nested interactive element', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]');
expect(badge?.tagName.toLowerCase()).toBe('span');
});
it('badge has small font size appropriate for a label', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]');
const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize);
expect(fontSize).toBeGreaterThan(0);
expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size
});
it('renders author name in meta line', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
expect(document.body.textContent).toContain('Anna Schmidt');
});
});

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import type { PersonOption } from '$lib/person/personOption';
interface Props {
status: 'DRAFT' | 'PUBLISHED';
selectedPersons: PersonOption[];
}
let { status, selectedPersons = $bindable() }: Props = $props();
const isDraft = $derived(status === 'DRAFT');
</script>
<aside class="flex flex-col gap-6">
<!-- Status section -->
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_sidebar_status()}
</summary>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_sidebar_status()}
</h2>
<p class="mb-3">
<span
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
? 'bg-muted text-ink-2'
: 'bg-accent-bg text-ink'}"
>
{isDraft ? m.geschichte_editor_status_draft() : m.geschichte_editor_status_published()}
</span>
</p>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_status_draft_hint()
: m.geschichte_editor_status_published_hint()}
</p>
</section>
</details>
<!-- Persons section -->
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_editor_personen_heading()}
</summary>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_personen_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
</details>
</aside>

View File

@@ -4,10 +4,10 @@ import type { components } from '$lib/generated/api';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatDate } from '$lib/shared/utils/date';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props {
geschichten: Geschichte[];
geschichten: GeschichteSummary[];
personId: string;
personName: string;
canWrite: boolean;
@@ -18,16 +18,16 @@ let { geschichten, personId, personName, canWrite }: Props = $props();
const visible = $derived(geschichten.slice(0, 3));
const hasOverflow = $derived(geschichten.length >= 3);
function formatPublishedDate(g: Geschichte): string | null {
function formatPublishedDate(g: GeschichteSummary): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
function authorName(g: Geschichte): string {
function authorName(g: GeschichteSummary): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
return full || '[Unbekannt]';
}
</script>

View File

@@ -3,19 +3,19 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
const makeStory = (id: string, title: string, body: string | undefined = '<p>Body</p>') => ({
id,
title,
body,
status: 'PUBLISHED' as const,
type: 'STORY' as const,
publishedAt: '2024-04-01T12:00:00',
createdAt: '2024-03-01T12:00:00',
updatedAt: '2024-04-01T12:00:00',
persons: [],
documents: [],
items: [],
author: {
id: 'u1',
email: 'marcel@example.com',
firstName: 'Marcel',
lastName: 'Raddatz',
enabled: true,
@@ -120,6 +120,16 @@ describe('GeschichtenCard', () => {
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
});
it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => {
render(GeschichtenCard, {
geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
});
it('renders a plain-text excerpt without HTML markup', async () => {
render(GeschichtenCard, {
geschichten: [

View File

@@ -2,20 +2,29 @@ import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
import type { components } from '$lib/generated/api';
type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(cleanup);
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Reise nach Berlin',
body: '<p>Brief text</p>',
publishedAt: '2026-04-15T10:00:00Z',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown,
...overrides
});
const makeGeschichte = (overrides: Record<string, unknown> = {}): GeschichteSummary =>
({
id: 'g1',
title: 'Reise nach Berlin',
body: '<p>Brief text</p>',
status: 'PUBLISHED' as const,
type: 'STORY' as const,
publishedAt: '2026-04-15T10:00:00Z',
author: {
firstName: 'Anna',
lastName: 'Schmidt'
},
...overrides
}) as GeschichteSummary;
const baseProps = (overrides: Record<string, unknown> = {}) => ({
geschichten: [] as ReturnType<typeof makeGeschichte>[],
geschichten: [] as GeschichteSummary[],
personId: 'p-1',
personName: 'Anna Schmidt',
canWrite: false,
@@ -93,17 +102,17 @@ describe('GeschichtenCard', () => {
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
});
it('falls back to author email when no name', async () => {
it('falls back to [Unbekannt] when no name', async () => {
render(GeschichtenCard, {
props: baseProps({
geschichten: [
makeGeschichte({
author: { firstName: undefined, lastName: undefined, email: 'fallback@x' }
author: { firstName: undefined, lastName: undefined }
})
]
})
});
await expect.element(page.getByText(/fallback@x/)).toBeVisible();
await expect.element(page.getByText('[Unbekannt]')).toBeVisible();
});
});

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
interface Props {
alreadyAddedIds?: Set<string>;
onAddDocument: (doc: DocumentOption) => void;
onAddInterlude: (text: string) => void;
}
let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $props();
let showPicker = $state(false);
let showInterludeForm = $state(false);
let interludeDraft = $state('');
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
function handleDocumentSelect(doc: DocumentOption) {
showPicker = false;
onAddDocument(doc);
}
function handleInterludeConfirm() {
if (!canConfirmInterlude) return;
const text = interludeDraft.trim();
interludeDraft = '';
showInterludeForm = false;
onAddInterlude(text);
}
function handleInterludeCancel() {
interludeDraft = '';
showInterludeForm = false;
}
</script>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap gap-2">
<button
type="button"
data-add-document
onclick={() => {
showPicker = !showPicker;
showInterludeForm = false;
}}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
+ {m.journey_add_document()}
</button>
<button
type="button"
onclick={() => {
showInterludeForm = !showInterludeForm;
showPicker = false;
}}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
+ {m.journey_add_interlude()}
</button>
</div>
{#if showPicker}
<DocumentPickerDropdown
alreadyAddedIds={alreadyAddedIds}
onSelect={handleDocumentSelect}
placeholder={m.journey_add_document()}
/>
{/if}
{#if showInterludeForm}
<div class="flex flex-col gap-2">
<textarea
bind:value={interludeDraft}
placeholder={m.journey_interlude_placeholder()}
rows={3}
maxlength={2000}
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<div class="flex gap-2">
<button
type="button"
onclick={handleInterludeConfirm}
disabled={!canConfirmInterlude}
class={[
'inline-flex h-11 items-center rounded px-4 font-sans text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring',
canConfirmInterlude
? 'bg-primary text-primary-fg hover:opacity-90'
: 'cursor-not-allowed bg-primary/40 text-primary-fg/60'
].join(' ')}
>
{m.journey_add_interlude_confirm()}
</button>
<button
type="button"
onclick={handleInterludeCancel}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_remove_confirm_cancel()}
</button>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyAddBar from './JourneyAddBar.svelte';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyAddBar — interlude flow', () => {
it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeDisabled();
});
it('confirm becomes enabled after typing text', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise');
const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeEnabled();
});
it('calls onAddInterlude with text on confirm', async () => {
const onAddInterlude = vi.fn();
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien');
});
it('limits the interlude textarea to 2000 characters', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await expect.element(page.getByRole('textbox')).toHaveAttribute('maxlength', '2000');
});
});
describe('JourneyAddBar — document picker', () => {
it('reveals picker when "Brief hinzufügen" is clicked', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
);
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_document()));
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
});

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