Compare commits

..

207 Commits

Author SHA1 Message Date
Marcel
8eb4a0ffde fix(geschichten): correct three bugs found in PR review
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m36s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m21s
CI / fail2ban Regex (pull_request) Successful in 52s
CI / Semgrep Security Scan (pull_request) Successful in 26s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
- Pass validated documentId (not raw) to /api/geschichten — Spring backend
  declares @RequestParam UUID documentId; invalid strings cause HTTP 400
- rebuildUrl no longer deletes documentId unconditionally; clearAll and
  removeDocument handle their own documentId deletion explicitly, so
  removePerson/addPerson now preserve an active document filter
- Use ?? instead of || when falling back from doc.title to originalFilename,
  preserving an intentionally-empty title rather than overwriting it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:27:19 +02:00
Marcel
1de10986c3 feat(geschichten): integrate DocumentFilterChip into list page
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m21s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m48s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 26s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
Add DocumentFilterChip to the filter bar, extract emptyMessage as $derived.by() with person-wins precedence, and add removeDocument navigation helper. Update tests: add document-filter chip and empty-state-precedence suites, fix person-chip click test to use native element.click() + vi.waitFor() for reliable Svelte 5 onclick triggering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:06:38 +02:00
Marcel
dcc9a25fdc feat(geschichten): add DocumentFilterChip component with spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:04:58 +02:00
Marcel
03e0dae5aa feat(geschichten): resolve document title in loader, return documentFilter object
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:04:58 +02:00
Marcel
7b858e5afd feat(geschichten): add i18n keys for document filter chip and empty state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:03:12 +02:00
Marcel
db081b0488 docs(timeline): add Zeitstrahl visual specs (global Concept A, event editor)
Visual design specs for Milestone #14:
- zeitstrahl-global-concepts.html — A/B/C exploration of the global timeline
- zeitstrahl-final-spec.html — canonical Concept A (global + per-person Lebensweg)
- zeitstrahl-event-editor-spec.html — curator event editor + document quick-action

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:03:12 +02:00
Marcel
ebe4198be9 docs(timeline): add Person date+precision migration as foundational issue
Replace Person birthYear/deathYear integers with birthDate/deathDate +
DatePrecision so known exact birthdays render precisely. Migration,
re-import preservation rule, and bounded blast radius captured; becomes
issue 1 the timeline's derived events depend on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:03:12 +02:00
Marcel
57aeb1ec7b docs(timeline): add family timeline (Zeitstrahl) design spec
Hand-curated, year-banded vertical timeline weaving derived person
life-events, curated personal/historical events, and date-placed
letters. Includes proposed sub-issue breakdown for a milestone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:03:12 +02:00
Marcel
09df6862c0 chore(api): regenerate TypeScript types after GET /api/geschichten annotation
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m9s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m48s
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
Adds @description JSDoc strings to status, personId, documentId and limit
query params in api.ts. Types (shapes) are unchanged — only description
strings added (refs #794).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:48:28 +02:00
Marcel
56935b4331 docs(geschichte): annotate query parameters on GET /api/geschichten
Adds @Parameter descriptions to status, personId, documentId, and limit
on GeschichteController.list() so Swagger UI shows semantics including the
permission-gated PUBLISHED override and DRAFT author-scoping for status,
the AND-filter semantics for personId, and the default/cap for limit (refs #794).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:42:27 +02:00
Marcel
2568a02d5a test(geschichte): add documentId, limit and status routing tests to GeschichteControllerTest
Adds list_passesDocumentIdFilterToService, list_passesLimitToService and
list_passesStatusFilterToService — characterisation tests for existing
parameter routing that had no controller-level coverage (refs #794).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:41:05 +02:00
Marcel
6e1ba13ca2 chore(frontend): ignore src.main/ in prettier
src.main/ is an untracked generated artefact that was not covered by
the existing ignore patterns, causing pre-commit lint failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 07:40:24 +02:00
Marcel
f65296172f test(geschichte): document FK-load-bearing cleanup order (#805)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m28s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 4m57s
CI / fail2ban Regex (pull_request) Successful in 53s
CI / Semgrep Security Scan (pull_request) Successful in 28s
CI / Compose Bucket Idempotency (pull_request) Successful in 37s
Adds a comment on the @AfterEach deletion sequence explaining that journey_items
must be removed before their referenced documents and geschichten.

Addresses @sara review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:23:33 +02:00
Marcel
6230deaa96 docs(architecture): draw JourneyItemDocumentDeleteListener event flow (#805)
Adds the JourneyItemDocumentDeleteListener component and the
DocumentDeletingEvent edge (documentSvc -> listener) to the supporting-domains
l3 diagram. This is the first event-driven edge in the backend; the diagram
must make that in-process coupling visible per the doc-currency rule.

Addresses @markus review (blocker) on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:22:52 +02:00
Marcel
00baf0d881 refactor(geschichte): make flush-before-delete explicit on note-less cleanup (#805)
Adds flushAutomatically = true to @Modifying on deleteNoteLessByDocumentId so
the flush-before-bulk-delete contract is explicit instead of relying on
Hibernate AUTO flush-mode behaviour.

Addresses @markus review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:22:12 +02:00
Marcel
84c5ff5b3f docs(audit): correct DOCUMENT_DELETED payload javadoc to none (#805)
The call site passes a null payload and carries the id in the documentId
column (matching FILE_UPLOADED), so the javadoc claiming Payload:
{"documentId": "uuid"} misdescribed the audit schema. Audit javadocs are the
contract forensic queries are written against.

Addresses @felix, @nora and @elicit review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:21:31 +02:00
Marcel
e9b8920b3b refactor(document): import ApplicationEventPublisher instead of FQN field (#805)
Replaces the fully-qualified org.springframework.context.ApplicationEventPublisher
field declaration with an import + simple type name, consistent with every
other field in DocumentService.

Addresses @felix review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:20:49 +02:00
Marcel
c2b98e6e84 fix(document): record actorId on DOCUMENT_DELETED audit (#805)
deleteDocument now threads the acting user through to the audit log so
DOCUMENT_DELETED records who deleted the document, matching every other
audited write path (storeDocument, updateDocument, applyBulkEditToDocument,
attachFile). Tightens the AC-7 assertion from any() to the concrete actor.

Also adds the missing ApplicationEventPublisher @Mock to DocumentServiceTest,
which the publishEvent call (added with the cascade fix) left null.

Addresses @nora (CWE-778, blocker), @felix and @sara review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:17:52 +02:00
Marcel
eefc67bd81 docs(adr): ADR-038 — domain event drives note-less journey-item cleanup on document delete (#805)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m49s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 6m43s
CI / fail2ban Regex (pull_request) Successful in 58s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 17:55:38 +02:00
Marcel
44869d64f7 fix(geschichte): delete note-less journey items before document delete to prevent chk constraint 500 (#805)
Publishes DocumentDeletingEvent from DocumentService.deleteDocument before
deleteById; JourneyItemDocumentDeleteListener handles it synchronously so
note-less items are gone before ON DELETE SET NULL fires on note-carrying rows.
Plain @EventListener chosen over AFTER_COMMIT (fires too late) and @Async
(breaks rollback atomicity) — see ADR-038. Adds DOCUMENT_DELETED to AuditKind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 17:54:54 +02:00
Marcel
7ca6492fc0 test(geschichte): rename journey items integration test — drop misleading end_to_end suffix (#795)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 6m9s
CI / OCR Service Tests (pull_request) Successful in 28s
CI / Backend Unit Tests (pull_request) Successful in 5m28s
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 1m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:15:24 +02:00
Marcel
9ea21f60ea test(geschichte): replace waitForDebounce sleep with retrying locator waits (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:14:16 +02:00
Marcel
a6184fa121 test(geschichte): add 403, catch-path, and CSRF header coverage for StoryDocumentPanel (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:12:43 +02:00
Marcel
232721214d refactor(geschichte): CSS.escape in focus queries, announceTimer cleanup, warning icon (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:11:12 +02:00
Marcel
d91bedbaaf fix(geschichte): restore focus to item remove button after failed DELETE rollback (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:09:26 +02:00
Marcel
8e4810d5da chore(i18n): delete dead keys geschichte_editor_dokumente_heading/hint (zero usages) (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:07:33 +02:00
Marcel
05652a18ee docs(adr): ADR-037 — journey_items serves both Geschichte subtypes (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:06:39 +02:00
Marcel
6b2dd2f259 docs(glossary): expand JourneyItem and Geschichte STORY definitions — both subtypes (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:05:42 +02:00
Marcel
f43df6082d docs(architecture): update JourneyItemService C4 description — no type guard, both subtypes (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:04:49 +02:00
Marcel
2121d8469f docs(geschichte): add StoryDocumentPanel to component inventory + C4 diagram (#795)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m9s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m23s
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 1m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:41:11 +02:00
Marcel
e8437b79d1 feat(geschichte): wire StoryDocumentPanel into the story editor sidebar (#795)
GeschichteSidebar gains optional geschichteId/items props and renders the
panel only when geschichteId is set. GeschichteEditor derives both from
its existing GeschichteView prop — null on /geschichten/new, so the panel
stays hidden there (create-then-edit, same as journeys). JourneyEditor's
sidebar call site is untouched, so journeys never show the panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:39:48 +02:00
Marcel
4f0a660cb8 feat(geschichte): StoryDocumentPanel — sidebar document management for stories (#795)
Sidebar-section-styled panel (p-4 card, mobile <details> accordion, no
inner scroll clamp) that lists a story's journey items in position order.
Add is pessimistic via POST /items; remove is optimistic with snapshot
rollback via DELETE /items/{id}; both through csrfFetch. Already-linked
documents are unselectable in the reused DocumentPickerDropdown (visible
label wired via inputId). Document-less items (ON DELETE SET NULL)
render as removable placeholder rows. 409 capacity/duplicate map to
story-worded messages, everything else through getErrorMessage(). Add/
remove are announced in a polite live region and focus moves to the
previous row's remove button (picker input when the list empties).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:35:32 +02:00
Marcel
48f2c67ffc feat(i18n): story document panel keys in de/en/es (#795)
Panel header, hint, empty state, picker label + placeholder, deleted-
document placeholder, remove-button label, live-region announcements,
and the story-worded capacity/duplicate errors (the generic
JOURNEY_AT_CAPACITY / JOURNEY_DOCUMENT_ALREADY_ADDED messages say
"Lesereise" — the wrong frame inside a STORY panel).

Side effect: the JSON round-trip collapsed three pre-existing duplicate
keys per locale (identical values, last-wins either way) —
error_geschichte_title_too_long, error_geschichte_intro_too_long,
person_unknown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:25:32 +02:00
Marcel
4961c74a01 feat(document-picker): optional inputId prop for external label wiring (#795)
The input always carries an id (generated doc-picker-input-{uid} default,
mirroring the listbox id scheme). When a caller passes inputId it wires a
visible <label for> — the aria-label fallback is dropped then so the
visible label wins. JourneyAddBar stays untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:23:45 +02:00
Marcel
d0fc8ce995 feat(geschichte): allow journey items on STORY-type Geschichten (#795)
Delete the JOURNEY-only type guard in JourneyItemService.append() so the
existing item endpoints serve both Geschichte types. GeschichteType has
exactly two constants, so an allowlist replacement would be unreachable
dead code. Fix the not-found messages that claimed "Journey", and remove
the now-orphaned GESCHICHTE_TYPE_MISMATCH error code end to end
(ErrorCode, errors.ts union + mapping, i18n keys in de/en/es).

Tests: three STORY append unit tests written red against the guard, plus
end-to-end STORY coverage (append+retrieve, V72-style position-gap rows
incl. removal, dangling document-deleted item). The two STORY-rejection
tests die with the guard — no third enum value exists to feed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:21:33 +02:00
Marcel
73b10e6f8c refactor(journey-editor): extract appendItem from the two add handlers
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m13s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 4m24s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
handleAddDocument/handleAddInterlude were identical except the POST
body. Behavior unchanged — all 35 editor specs stay green.

Review round 3: Felix suggestion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:33:41 +02:00
Marcel
ac6da154da docs: round-3 currency pass
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 4m9s
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 1m7s
- extractText.ts states the real safety invariant (output is text-only;
  JOURNEY intros arrive unsanitised by design)
- geschichte README: stale glyph/pill cells updated, formatAuthorName no
  longer claims an email fallback, formatDocumentMetaLine documented
- reader spec HTMLs: bg-white list-card cell and the mobile BottomSheet
  rows struck with the implemented decision (inline metabar actions)

Review round 3: Nora (2), Markus (2), Felix, Elicit (3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:31:17 +02:00
Marcel
66e9309d8a fix(geschichte-ui): a11y and visual round-3 batch
- JourneyItemCard: 'Brief öffnen' back to a >=44px touch target with the
  height regression spec restored
- GeschichteListRow: REISE badges text-[10px] -> text-xs; drop the
  hardcoded aria-label and the mobile badge's aria-hidden so phone screen
  readers learn a row is a Lesereise; mobile avatar initials -> color dot
- detail page: badge text-xs, metabar Edit/Delete h-9 -> h-11, avatar
  color keyed by name to match the list
- JourneyReader: dead border-subtle class -> border-line-2
- DocumentPickerDropdown: aria-controls only while the listbox exists
- JourneyAddBar: aria-expanded/aria-controls on both toggles + focus
  hand-off into the revealed picker input / interlude textarea
- GeschichteSidebar: section h2s hidden below sm (summary already shows
  the label there)
- JourneyCreate: bg-brand-navy -> semantic bg-primary/text-primary-fg;
  title maxlength=255
- JourneyItemRow interlude: neutral frame border + left accent only,
  token utilities instead of arbitrary var() syntax and inline style

Review round 3: Leonie (1-8 + round-1 leftovers), Elicit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:30:59 +02:00
Marcel
836c9594d4 refactor(person): one author-name fallback, localized
joinNameOrUnknown()/unknownPersonName() in personFormat.ts replace the
three hand-rolled '[Unbekannt]' literals (geschichte/utils,
GeschichtenCard, personOption) — the fallback is now the i18n key
person_unknown (de/en/es), and formatAuthorDisplayName localizes the
server-side literal on the pass-through path.

Review round 3: Felix, Markus, Leonie (7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:30:23 +02:00
Marcel
07725cfa39 fix(typeahead): close() cancels the pending debounce
An abandoned query no longer fires a stale fetch after the dropdown
closed, and loading can't stay stuck on true. Test pins both.

Review round 3: Felix suggestion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:29:25 +02:00
Marcel
b4fcbd7efc fix(geschichte): uniform onSubmit rejects-on-failure contract
The c3afd57e fix made the edit page's handleSubmit throw on !res.ok but
only JourneyEditor caught it. Now: GeschichteEditor.save() catches and
keeps its dirty state (no unhandled rejection -> no GlitchTip noise on a
failed STORY save); StoryCreate throws on failure so a failed STORY
create no longer silently disarms the unsaved guard; both handleSubmit
implementations catch network rejections, surface a message, and rethrow.
Contract documented on both editors' Props. GeschichteEditor also gets
the title maxlength=255. Spec: rejecting onSubmit is caught and the
editor stays usable.

Review round 3: Felix §2, Tobias S1, Nora (3), Markus (concern 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:29:08 +02:00
Marcel
8995b6e922 fix(journey-editor): note UX, error codes, list semantics, a11y labels
- handleNotePatch routes failures through failureMessage() so a backend
  JOURNEY_NOTE_TOO_LONG renders its specific message in the row
- handleNoteBlur: '' vs undefined no-op guard (no spurious PATCH
  {note:null}), empty blur collapses the textarea, clearing an existing
  note collapses after the PATCH lands (spec LE-3)
- 'Notiz hinzufügen' toggle gets aria-expanded and moves focus into the
  revealed textarea
- journey_remove_item_aria interpolates the item title (de/en/es); dead
  journey_drag_aria_label key deleted from all locales
- editor item list is an <ol> (screen readers announce the ordering)
- editor title input gets maxlength=255 + border-danger error cue; intro
  textarea gets maxlength=4000
- Briefmeta line (date · von X an Y) under document titles in the editor
  row via the shared formatDocumentMetaLine (spec LE-2)
- new specs: successful save clears the unsaved warning; item add does
  not arm the guard; server-confirmed order after successful reorder;
  blur-without-typing no-op; focus hand-off into the note textarea

Review round 3: Sara (1-3), Felix, Elicit (LE-2/LE-3), Leonie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:28:38 +02:00
Marcel
b9bed19610 feat(geschichte): bound title and JOURNEY intro at all three layers
Title: requireTitle() turns the VARCHAR(255) DB bound into a friendly 400
(GESCHICHTE_TITLE_TOO_LONG). JOURNEY intro: MAX_INTRO_LENGTH = 4000 in
bodyForType() plus a V75 CHECK as atomic backstop (defensive clamp first,
STORY bodies exempt) — GESCHICHTE_INTRO_TOO_LONG. Both codes wired through
ErrorCode.java, errors.ts, getErrorMessage and de/en/es. DB-layer boundary
pins added: exactly-2000 note insert (V74 CHECK) and 4000/4001 intro
inserts against real Postgres. Docs: error-code lists, db puml diagrams.

The matching maxlength attributes land with the component commits.

Review round 3: Markus (b), Nora (1), Sara (DB 2000 boundary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:28:08 +02:00
Marcel
98805f596a fix(db): make V74 self-healing on databases that ran the base branch
Dedup DELETE + note clamp before the DDL so the unique index and CHECK
cannot fail mid-migration (failed Flyway row -> backend boot loop) on a
DB that served writes under the old code (no dedup guard, 5000-char
notes). No-ops on a clean database.

Note: this changes V74's checksum — dev databases that already ran V74
on this branch need 'flyway repair' (or a fresh DB). CI and prod run
from V73 or clean and are unaffected.

Review round 3: Tobias (1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:51:20 +02:00
Marcel
2e9902e8a7 fix(journeyitem): translate only the dedup-index violation to 409
Any other DataIntegrityViolationException at flush (e.g. an FK violation
on a concurrently deleted document) is rethrown instead of being
mislabeled as JOURNEY_DOCUMENT_ALREADY_ADDED. Match on the
uq_journey_items_geschichte_document constraint name.

Review round 3: Markus (a), Felix suggestion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:49:31 +02:00
Marcel
0884a1abcd fix(geschichten): widen list meta column so text-sm names fit
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m49s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m6s
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
The author column stayed at w-28 after the typography bump, wrapping
names into a cramped two-line stack. w-40 gives name and date one
comfortable line each at the full-width layout.

Refs #802
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:55:29 +02:00
Marcel
50def73d80 fix(geschichten): scale list row typography to the full-width layout
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m57s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m9s
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
Rows kept their compact sizes (15px titles, 12px excerpts/meta) after
the overview widened to max-w-7xl, leaving the text undersized in
full-width rows. Title is now text-lg, excerpt and meta text-sm; R-1
impl-ref updated.

Closes #802
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:53:40 +02:00
Marcel
264d7268c4 fix(geschichte-detail): span directory width with centered reading column
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m9s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m14s
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
The detail page stayed at max-w-3xl and looked narrower than every
other page. The outer container now matches the directory width
(max-w-7xl) so the sheet spans the page like Dokumente/Personen, while
an inner max-w-3xl column keeps the prose line length readable.

Refs #799
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:49:56 +02:00
Marcel
2d38122833 fix(geschichte): use mode-aware journey tokens for interlude and badge
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m45s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m8s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
The reader interlude and the LESEREISE badge hardcoded orange-50/200/
400/700, leaving light text on a light background in dark mode. Switch
to the existing journey-tint/journey/journey-border tokens already used
by the list and editor rows.

Closes #801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:42:26 +02:00
Marcel
9c524443cf fix(geschichte): raise journey reading text to the accessibility floor
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m49s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 4m7s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
Intro, letter titles, curator annotations, and interludes rendered at
12-14px — copied from the scaled mockup sizes in LR-2. Narrative text
is now 16-18px, meta lines and links 14px; the LR-2 impl-ref table is
updated to match.

Closes #800
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:38:59 +02:00
Marcel
3b58ac0457 fix(geschichten): match directory width of Dokumente/Personen overviews
The list page used max-w-4xl while every other directory page uses
max-w-7xl. The detail page intentionally stays max-w-3xl (reading
column per spec R-2).

Closes #799
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:36:09 +02:00
Marcel
63acb5417f feat(ui): add sheet surface token between canvas and white cards
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m56s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 4m8s
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 1m10s
The reading sheet used bg-surface (white), so the document cards inside
the article had the same background as the sheet itself. The spec's
three-level hierarchy is canvas → article panel (#FAFAF7) → white cards;
introduce --color-sheet (mode-aware) and use it on the article. Also
move JourneyItemCard from bg-white to bg-surface so dark mode remaps it,
and tint the curator annotation with bg-muted so it stands off the card.

Refs #797
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:32:10 +02:00
Marcel
b926bdefde feat(geschichte-detail): render article on a reading-sheet surface card
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m59s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m12s
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 1m11s
The R-2 mockup shows the article on a distinct light panel, but the
impl-ref table only specified the centered container, so the page
rendered flat on the canvas. Wrap the <article> in the standard card
pattern (BackButton stays outside on the canvas) and record the sheet
in both spec impl-ref tables so mockup and impl-ref agree.

Closes #797
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:24:37 +02:00
Marcel
cbf4432d01 fix(geschichte): use brand-mint token for journey annotation border
border-mint is not a generated utility (the token is --color-brand-mint),
so the border-l-2 fell back to currentColor and rendered navy instead of
the mint accent specified in LR-2.

Closes #798
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:53 +02:00
Marcel
f2ebaacfb1 test(journey-interlude): drop stale section-break glyph expectation
The interlude was redesigned to the orange-left-border block from spec
LR-2 (no ❦ glyph), but the spec test still asserted the glyph and was
red at HEAD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:00:27 +02:00
Marcel
07ed9719e7 feat(geschichte-detail): avatar metabar + doc reference cards per spec R-2/LR-2
Detail header gains the author avatar with a two-line author block;
journeys say 'zusammengestellt am' instead of 'veröffentlicht am'.
Bearbeiten/Löschen move into the metabar for stories too (were at the
article bottom). StoryReader renders real document reference cards
(icon, title, date · von X an Y) instead of a placeholder link, person
chips get avatar initials, and journey items lose the doubled spacing.
Shared formatDocumentMetaLine() in geschichte/utils feeds both readers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:59:56 +02:00
Marcel
906e75ec96 feat(geschichten-list): editorial list card per spec R-1/LR-1
Single white list card with the person-filter pills inside, rows split
into an author meta column (avatar, name, date, REISE badge) and a
content column (serif title, two-line excerpt). Badge moves into the
meta column on desktop and next to the title on mobile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:59:17 +02:00
Marcel
90a1bd4082 feat(journey-reader): match spec LR-2 — card layout, interlude, badge, actions; inline note in editor row
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m3s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m11s
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
JourneyItemCard: restructure from full-<a> to div+card with meta line
(date · von X an Y) and explicit "Brief öffnen →" link; note renders as
mint-border annotation inside the card.

JourneyInterlude: remove ❦ ornament; orange-400 left-border spec classes.

JourneyReader: fix intro classes (dashed border-b); remove bottom author
actions (moved to +page.svelte metabar).

+page.svelte geschichten/[id]: badge above title with spec orange-50 classes;
Bearbeiten/Löschen in metabar right side for isJourney + canBlogWrite.

JourneyItemRow: items-center on main row; drag handle self-center; note
textarea inline in content column (removes border-t section below).

i18n: add journey_item_open, journey_item_meta_from_to to de/en/es.

Tests: update JourneyItemCard + JourneyReader specs to match new structure;
fix datePrecision 'FULL'→'DAY', add receiverCount: 0 to all test fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:59:32 +02:00
Marcel
b0d75b26cd fix(journey-create): catch network rejections and surface error alert
try/finally without catch swallowed TypeError network failures silently.
Added catch block that sets errorMessage so the UI shows role=alert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:41:11 +02:00
Marcel
166003b33a fix(journey-editor): selectedPersons change calls markDirty via $effect
Skip-first-run $effect tracks selectedPersons array length; any add/remove
after mount marks the editor dirty so the unsaved-warning fires on nav.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:39:44 +02:00
Marcel
7ad2132a92 test(journey-editor): fix misleading test name for item-add + publish
Removed the incorrect claim "isDirty stays false" from the test name.
The banner tests added in the previous commit cover the isDirty signal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:36:48 +02:00
Marcel
c3afd57e19 fix(journey-editor): unsaved-warning banner + save throws on failure
JourneyEditor now renders UnsavedWarningBanner when showUnsavedWarning
is true. save() wraps onSubmit in try/catch so clearOnSuccess only fires
on success. edit/+page.svelte handleSubmit throws instead of returning
on non-ok responses so JourneyEditor sees the failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:36:18 +02:00
Marcel
74b94ccd84 fix(journey-editor): remove-confirm focus, role=group, Escape key
Focus moves to Cancel when confirm appears (no focus-drops-to-body),
confirm area has role=group with aria-label, and Escape dismisses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:32:16 +02:00
Marcel
585244d65b fix(picker): ARIA combobox — listbox only when results; no tabindex on options
Status/error/empty messages rendered outside <ul role="listbox"> so the
element only contains role="option" children (fixes aria-required-children
axe violation). Options no longer carry tabindex or onkeydown — keyboard
navigation stays on the input per ARIA combobox pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:30:18 +02:00
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
215 changed files with 1160 additions and 13300 deletions

View File

@@ -1,99 +0,0 @@
---
name: draft-spec
description: Requirements-engineer-led authoring of a new feature spec. Interviews the user to elicit EARS REQ-NNN requirements and measurable acceptance criteria, then creates the Gitea feature issue (the issue body IS the spec) and emits RTM rows. Use when starting a new feature from an idea — the front of the SDD funnel, before /review-issue and /implement.
---
# Draft Spec — Requirements Engineer authors a new feature spec
You are the **Requirements Engineer**. Read your full persona from
[`.claude/personas/req_engineer.md`](../../personas/req_engineer.md) and adopt its voice and
priorities. Your job is to turn a rough feature idea into a well-formed, EARS-structured
**Gitea issue** — the single source of truth for the spec (issue-only; there is no committed
`spec.md`). You *author* the spec; you do **not** approve it — that's `/review-issue`'s job.
## Argument
A free-text feature idea, e.g. `users should be able to upload a profile picture`. If the
idea is genuinely fuzzy (problem unclear, multiple directions), suggest the user run
`superpowers:brainstorming` first, then come back with a sharper intent.
## Phase 0 — Load the SDD ground truth
Read before interviewing:
- [`.specify/constitution.md`](../../../.specify/constitution.md) and [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — the rules the spec must respect
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the section structure and the five EARS patterns
- [`.specify/personas/requirements-engineer.md`](../../../.specify/personas/requirements-engineer.md) — **your own checklist; apply it as you write, not after**
- [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
- [`docs/GLOSSARY.md`](../../../docs/GLOSSARY.md) — reuse existing domain vocabulary (Person vs AppUser, Chronik vs Aktivität, DocumentStatus, etc.)
Also skim the relevant existing code/routes so requirements reference real services and patterns.
## Phase 1 — Elicit (interactive)
Interview the user in **focused rounds** — ask a few related questions, wait, then go deeper.
Do not dump one giant questionnaire. Cover, in roughly this order:
1. **Why & who** — the business motivation and the role(s) involved. Drives the issue title
`As a <role> I want <capability> so <reason>`.
2. **User journey** — the plain-prose happy path, from the user's perspective. This bounds scope.
3. **Happy-path behaviors** — what the system does on success. Each becomes a Ubiquitous,
Event-driven, or State-driven requirement.
4. **The unwanted paths — probe hard, this is where specs fail.** For every mutating action
ask: what if the caller is unauthenticated? unauthorized? what input is invalid, and what's
the limit (size, count, length)? what's the exact response (`ErrorCode` + HTTP status)?
Each answer is an Unwanted-behavior (`If …`) requirement. (Checklist item #7 is your prompt bank.)
5. **Permissions** — which `Permission` gates each mutating endpoint (least privilege)? Each
gate is an Optional-feature (`Where …`) requirement.
6. **Data model** — new tables/columns/constraints? the next free Flyway `V<n>` (you'll verify on disk)?
7. **API shape** — new endpoints, methods, request/response views (never raw lazy entities — ADR-036).
8. **Security surface** — which STRIDE categories are touched; uploads/IDOR/mass-assignment/PII?
9. **Out of scope** — name the nearest tempting scope creep and exclude it.
10. **Open questions** — anything you cannot decide; these block until resolved.
Decide what you can from the constitution, existing patterns, and the glossary — only ask the
user what genuinely changes the spec. Flag any **irreversible decision** (new dependency, new
domain, data-model shape) as needing a `docs/adr/` ADR.
## Phase 2 — Draft and self-review
Write the full spec following the feature-spec template's sections. Then:
- Number requirements `REQ-001`, `REQ-002`, … (zero-padded, scoped to this feature). Each uses
exactly one EARS pattern. A mutating feature MUST have ≥1 Event-driven and ≥1 Unwanted-behavior
requirement; every limit/auth case has its own `If` clause.
- Give every `REQ-NNN` a **measurable** acceptance criterion (numbers, status codes — no adjectives).
- Run your `requirements-engineer.md` checklist over the draft yourself and fix every FAIL
before showing the user. (You're allowed to block your own draft.)
- Present the full draft to the user. Refine until they confirm. **Do not create the issue
until the user approves the draft text.**
## Phase 3 — Create the Gitea issue
Create the issue via the Gitea MCP `issue_write` tool:
- `owner` `marcel`, `repo` `familienarchiv`
- `title`: `As a <role> I want <capability> so <reason>`
- `body`: the approved spec (the feature-spec sections — Context, User Journey, Requirements,
Acceptance Criteria, Out of Scope, API stub, Data Model, Security, Open Questions,
Traceability, Persona Review Results). Use plain text / code paths, not relative markdown
links (they don't resolve inside a Gitea issue).
- **Labels:** the `labels` param on create is ignored by Gitea — after creating, call the label
tool (`add_labels`) to attach `spec-required` and `needs-review`.
## Phase 4 — Emit RTM rows + flag ADRs
- Emit ready-to-paste [`.specify/rtm.md`](../../../.specify/rtm.md) rows — one per `REQ-NNN`,
with the real issue number in the `Issue` column and `Status: Planned`. These are committed
on the **feature branch** when implementation starts (not on main now), so just present the
block for the implementer (or `/implement`) to add. If you're already on the feature's
worktree/branch, append them to `rtm.md` directly.
- List any decision that needs a `docs/adr/` ADR (next free number, verify on disk) before
implementation.
## Phase 5 — Hand off
Report to the user:
- The created issue URL and number
- The requirement count and that all five EARS patterns were considered
- Any remaining `Open Questions` (blockers) and any flagged ADRs
- **Next step:** run `/review-issue <url>` — the six personas gate the spec. You authored it;
you don't self-approve. After it passes and Open Questions are empty, run `/implement <url>`.

View File

@@ -3,17 +3,10 @@ name: implement
description: Felix Brandt reads a Gitea issue or Pull Request, clarifies ambiguities with the user, presents an implementation plan for approval, then works autonomously using red/green TDD until every task is done and committed.
---
# Implement — Felix Brandt's Spec-Driven TDD Workflow
# Implement — Felix Brandt's Issue/PR-Driven TDD Workflow
You are Felix Brandt. Read your full persona from `.claude/personas/developer.md` before doing anything else.
Then load the SDD ground truth you must obey throughout:
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, executable constraints, workflow rules, do-not-touch list
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules AGENTS.md references
The feature's `spec.md` (its `REQ-NNN` requirements) is the contract. Implement exactly what
the requirements say — no more, no less.
## Argument
The user provides a Gitea issue **or** pull request URL, e.g.:
@@ -54,19 +47,9 @@ Mark each concern with its source: reviewer name + comment excerpt.
Also read:
- `CLAUDE.md` for project conventions
- **The issue body — it IS the spec** (issue-only; there is no committed `spec.md`). Extract its
`REQ-NNN` requirements, acceptance criteria, API stub, data-model delta, and any inline
STRIDE/threat notes. These are your contract.
- [`.specify/rtm.md`](../../../.specify/rtm.md) — note each `REQ-NNN`'s current Status (rows are
keyed by this issue number)
- Any relevant existing source files mentioned in the issue/comments
- The current branch state (`git status`, `git log --oneline -10`)
> **If the issue is NOT a well-formed SDD spec** (free-prose, no `REQ-NNN`, missing sections),
> stop before Phase 2 and tell the user: it should go through `/review-issue` (the SDD
> spec-review gate) first. Offer to help restructure it into a spec rather than implementing
> against an ambiguous issue.
Do not start Phase 2 until you have read everything.
---
@@ -75,12 +58,10 @@ Do not start Phase 2 until you have read everything.
### Issue mode
First, check the spec's `## Open Questions`**any unresolved item there is a blocker** and
must be answered before implementation (SDD step 5). Then identify any further point that is
genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
- Scope questions (is X in or out? — check `## Out of Scope` first)
- A `REQ-NNN` that is not testable as written, or has no measurable acceptance criterion
- Design decisions with multiple valid approaches where the choice affects architecture (if it's an irreversible choice, it may need an ADR — flag it)
After reading, identify every point that is genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
- Scope questions (is X in or out of this issue?)
- Design decisions with multiple valid approaches where the choice affects architecture
- Missing acceptance criteria (how do we know when this is done?)
- Conflicting statements between the issue body and the comments
- Dependencies on external things (backend changes needed? migration required?)
@@ -100,15 +81,12 @@ Wait for the user to answer before continuing.
## Phase 3 — Implementation Plan
Once clarifications are resolved, present a numbered implementation plan as a task list,
**derived from the issue's `REQ-NNN` requirements** (one or more tasks per requirement, in
red/green order). Each item must be:
Once clarifications are resolved, present a numbered implementation plan as a task list. Each item must be:
- A single atomic unit of work (one behavior, one file change, one migration)
- Written as a sentence that implies the test name: "Tag detail page returns 404 when tag does not exist"
- Ordered so each item builds on the previous ones (red/green order — a failing test precedes its implementation)
- Ordered so each item builds on the previous ones
- Prefixed with the layer: `[backend]`, `[frontend]`, `[migration]`, `[test]`, `[refactor]`
- **In issue/SDD mode, tagged with the `REQ-NNN` it satisfies** so every requirement is covered and nothing extra is built. Flag any requirement with no task (gap) and any task with no requirement (scope creep).
**In PR mode**, each task must reference the reviewer concern it addresses, e.g.:
```
@@ -119,10 +97,10 @@ Format:
```
## Implementation Plan
1. [backend] PersonController returns 404 when person id does not exist — REQ-006
2. [migration] V<n> add index on documents.sender_id (verify next free number on disk) — REQ-002
3. [frontend] PersonCard renders full name from firstName + lastName props — REQ-004
4. [frontend] PersonCard shows placeholder when both names are null — REQ-004
1. [backend] PersonController returns 404 when person id does not exist
2. [migration] Add index on documents.sender_id for performance
3. [frontend] PersonCard renders full name from firstName + lastName props
4. [frontend] PersonCard shows placeholder when both names are null
...
```
@@ -167,22 +145,12 @@ Check the current branch.
2. Apply any needed clean-up — no new behavior
3. Run the full suite again to confirm still green
**Sync (SDD):**
1. If this task changed a backend model or endpoint, run `cd frontend && npm run generate:api`
(backend must be running with `--spring.profiles.active=dev`) and stage the regenerated types.
2. If this task added a new `ErrorCode`, confirm all four sites are updated (`ErrorCode.java`,
`frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`).
3. Flip the task's `REQ-NNN` Status in [`.specify/rtm.md`](../../../.specify/rtm.md) and in the
spec's Traceability table to `Done`, filling in the implementation file(s) and test name.
**Commit:**
Commit atomically after each task using the project's commit conventions, referencing the
issue (`Refs #n` / `Closes #n`) on the last line:
Commit atomically after each task using the project's commit conventions:
```
feat(scope): short imperative description
Refs #<n>
Co-Authored-By: <model> <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
```
Move to the next task immediately.
@@ -196,10 +164,8 @@ Move to the next task immediately.
### Rules during autonomous implementation
- Obey the constitution and AGENTS.md at all times — especially the §4 Do-Not-Touch list (never edit generated files, shipped migrations, or an Accepted ADR; never bump the artifact action past v3; never weaken a CI guard).
- Never skip the red step — if you cannot write a failing test for a task, stop and explain why to the user before writing any implementation code
- Never add behavior beyond what the current task requires — and never add behavior with no backing `REQ-NNN`. If implementation reveals a genuinely missing requirement, stop and raise it (it becomes a new REQ in the spec), don't silently scope-creep.
- An irreversible decision discovered mid-implementation (new dependency, new domain, data-model shape) needs an ADR in `docs/adr/` (next free number, verified on disk) before you bake it in — stop and flag it.
- Never add behavior beyond what the current task requires
- Never bundle two tasks into one commit
- If a test that was passing starts failing during a later task, fix it before continuing — do not leave broken tests
- If you hit a genuine blocker (missing API, infrastructure not available, etc.) that prevents completing a task, stop and report it to the user rather than working around it silently
@@ -212,16 +178,10 @@ After all tasks are done:
1. Run the full test suite one final time and confirm all green
2. Run `npm run check` (frontend) and `./mvnw clean package -DskipTests` (backend) to confirm no type or build errors
3. **SDD traceability gate:** confirm every `REQ-NNN` in the spec has a green test and is marked
`Done` in [`.specify/rtm.md`](../../../.specify/rtm.md). Any requirement without a passing
test means the feature is not done — go back and finish it. Confirm `generate:api` was run
if any backend model/endpoint changed.
### Issue mode
4. Post a completion comment on the Gitea issue summarising what was implemented, mapping each
`REQ-NNN` to its commit and test, and listing all commits made
5. Report back to the user: every task ✅, the REQ→test coverage, any skipped/deferred tasks
(with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
3. Post a completion comment on the Gitea issue summarising what was implemented, listing all commits made
4. Report back to the user: every task ✅, any skipped/deferred tasks (with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
### PR mode
3. Push the updated branch

View File

@@ -1,15 +1,13 @@
---
name: review-issue
description: Multi-persona SDD spec review of a Gitea feature issue. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, walks it PASS/FAIL/QUESTION against the EARS requirements, and posts findings as a separate Gitea comment before implementation starts.
description: Multi-persona feature issue review. Each persona from .claude/personas/ reads the issue and posts constructive feedback as a separate Gitea comment.
---
# Multi-Persona Spec Review (SDD)
# Multi-Persona Feature Issue Review
You will perform a thorough multi-persona **spec review** of the given Gitea feature issue and
post each persona's findings as a **separate comment** on the issue. This is the SDD
spec-review gate (step 4 of [SPEC_DRIVEN_DEVELOPMENT.md](../../../SPEC_DRIVEN_DEVELOPMENT.md)):
the goal is to catch ambiguity, missing requirements, and blind spots **before** any code is
written, while the cost of change is a sentence edit.
You will perform a thorough multi-persona review of the given Gitea issue URL and post each persona's constructive feedback as a **separate comment** on the issue.
Personas give **advisory input only** — no blocking, no verdicts. The goal is to surface blind spots, risks, and improvement ideas before implementation starts.
## Argument
@@ -21,83 +19,57 @@ Parse it to extract:
- `repo` — e.g. `familienarchiv`
- `issue_number` — e.g. `161`
## Step 0Load the SDD ground truth
Before reading the issue, read the rules every persona reviews against:
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, constraints, workflow
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the expected spec shape and the five EARS patterns
- The worked example [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
## Step 1 — Gather issue context
## Step 1Gather Issue Context
Use the Gitea MCP tools to collect:
1. The full issue (title, body, labels, milestone, assignees) via `issue_read`
2. All existing comments — read them so personas don't repeat what's already been said
2. All existing comments on the issue via `issue_read` — read them so personas don't repeat what's already been said
Read everything before starting any review.
## Step 2 — Read every persona (identity + checklist)
## Step 2 — Read Every Persona
Each persona is its **character identity** (`.claude/personas/`) **plus** its **SDD spec-review
checklist** (`.specify/personas/`). Adopt the voice from the former; gate the spec with the latter.
Read all six persona files from `.claude/personas/`:
- `developer.md` → Felix Brandt
- `architect.md` → architect persona
- `tester.md` → tester persona
- `security_expert.md` → security persona
- `ui_expert.md` → UI/UX persona
- `devops.md` → DevOps persona
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) |
|---|---|---|
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` |
| Developer (Felix Brandt) | `developer.md` | `developer.md` |
| Security (Nora "NullX" Steiner) | `security_expert.md` | `security.md` |
| DevOps | `devops.md` | `devops.md` |
| UI/UX | `ui_expert.md` | `ui-ux.md` |
| Architect | `architect.md` | `architect.md` |
## Step 3 — Write Each Review
The tester lens (acceptance-criteria quality, edge cases) is carried by the Requirements
Engineer checklist (testable, measurable criteria) — no separate tester comment at spec time.
For each persona, fully adopt their identity, priorities, and thinking style as described in their persona file. Write feedback that:
## Step 3 — Run each checklist against the spec
- Is **constructive and forward-looking** — no blockers, no verdicts, no approval stamps
- Asks clarifying questions the persona would genuinely want answered before or during implementation
- Points out risks, edge cases, or gaps the persona sees from their domain
- Offers concrete suggestions or alternative approaches where relevant
- References the issue text specifically — don't write generic advice
- Stays focused on what the persona would actually care about (e.g. Felix asks about test strategy and naming; the architect asks about layer boundaries and coupling; the security expert asks about auth, input validation, and data exposure; the tester asks about acceptance criteria and edge cases; the UI expert asks about interaction patterns and accessibility; DevOps asks about deployment, config, and observability)
For each persona, walk **every item** in its `.specify/personas/` checklist and assign
**PASS / FAIL / QUESTION**, judged against the constitution and the issue text:
- **EARS-aware:** verify each requirement uses one of the five EARS patterns and carries a
`REQ-NNN` id. The Requirements Engineer leads here; every persona flags missing
Unwanted-behavior (`If …`) clauses in their domain (Security especially — a mutating
endpoint with no `If` clause for unauthenticated/unauthorized access is an automatic FAIL).
- **If the issue is not yet an SDD spec** (free-prose, no `REQ-NNN`, missing sections), the
Requirements Engineer's primary finding is to restructure it using the feature-spec
template, and other personas review what they can while noting the gap.
- Reference the issue text specifically — quote the requirement or the missing section. No
generic advice.
## Step 4 — Write and post each comment
Each persona posts a **separate** comment via the Gitea MCP `issue_write` tool, in the format
its checklist's "Output format" section defines — a header, the checklist table, and a verdict:
Format each comment in Markdown with a persona header, e.g.:
```
### 🔐 Security — Spec Review
## 👨‍💻 Felix Brandt — Senior Fullstack Developer
| # | Item | Status | Note |
|---|------|--------|------|
| 1 | All mutating endpoints have authn + authz `If` clauses | FAIL | REQ-004 POST has no 401 clause (CWE-...) |
| 2 | ... | PASS | |
### Questions & Observations
...
**Verdict: CHANGES REQUESTED** — blocking FAIL: #1. Resolve before implementation.
### Suggestions
...
```
Post all six comments. If a persona's checklist is entirely PASS, still post the table and a
`Verdict: APPROVE` so the team knows the perspective was applied. Keep comments scannable.
Keep each comment focused and scannable. Use bullet points. Avoid walls of text.
These verdicts are a **pre-implementation gate**, not a PR merge gate: a `FAIL` means the
issue/spec must be amended (per SDD step 5) before work starts. Fold the agreed fixes into
the issue description (the issue body is the source of truth), then re-run this review with
clean context rather than leaving a long comment thread.
## Step 4 — Post Comments
## Step 5 — Report back
Post each persona's feedback as a **separate comment** on the issue using the Gitea MCP `issue_write` tool.
Post all six comments. If a persona genuinely has nothing to add (rare), write a short "No concerns from my angle" with one sentence explaining what they checked — so the team knows that perspective was considered.
## Step 5 — Report Back
After all comments are posted, tell the user:
- Each persona's verdict (APPROVE / CHANGES REQUESTED)
- The consolidated list of blocking FAILs (these must be resolved before implementation)
- Cross-cutting themes multiple personas flagged
- Whether the issue is a well-formed SDD spec yet, or needs restructuring first
- A reminder to mirror the agreed `REQ-NNN` rows into [`.specify/rtm.md`](../../../.specify/rtm.md)
- Which personas posted feedback
- A brief summary of the most important cross-cutting themes (questions or risks that multiple personas flagged)

View File

@@ -1,95 +1,74 @@
---
name: review-pr
description: Multi-persona SDD code review of a Gitea PR. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, verifies the diff against the constitution and the feature spec's REQ-NNN (every requirement implemented and tested), and posts findings as a separate Gitea comment.
description: Multi-persona PR review. Each persona from .claude/personas/ reviews the PR and posts their findings as a separate Gitea comment.
---
# Multi-Persona PR Review (SDD)
# Multi-Persona PR Review
You will perform a thorough multi-persona code review of the given PR and post each persona's
findings as a **separate comment**. Under SDD, the review verifies the diff against two
contracts: the project [constitution](../../../.specify/constitution.md) and the feature's
spec (the linked **Gitea issue body** — every `REQ-NNN` must be implemented **and** covered by a test).
You will perform a thorough multi-persona code review of the given PR URL and post each persona's findings as a **separate comment** on the PR.
## Argument
The user provides a Gitea PR URL, e.g.:
`http://heim-nas:3005/marcel/familienarchiv/pulls/160`
Parse it to extract `owner`, `repo`, and `pull_number`.
Parse it to extract:
- `owner` — e.g. `marcel`
- `repo` — e.g. `familienarchiv`
- `pull_number` — e.g. `160`
## Step 0Load the SDD ground truth
Read before reviewing:
- [`.specify/constitution.md`](../../../.specify/constitution.md) — rules the code must obey (esp. §4 Do-Not-Touch)
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — constraints
- The feature's spec — the **Gitea issue** the PR closes (`Closes #n`). Read its body for the
`REQ-NNN` requirements, acceptance criteria, inline API stub, and any STRIDE/threat notes.
- [`.specify/rtm.md`](../../../.specify/rtm.md) — the requirement→test→status matrix
## Step 1 — Gather PR context
## Step 1Gather PR Context
Use the Gitea MCP tools to collect:
1. PR metadata (title, description, base/head branch) via `pull_request_read`
2. The list of changed files
3. The full content of every changed file at the head commit via `get_file_contents`
1. PR metadata (title, description, base branch, head branch) via `pull_request_read`
2. The list of changed files via `get_dir_contents` or the PR files endpoint
3. The full diff / file contents of every changed file — read each file at the head commit using `get_file_contents`
Read ALL changed files completely before starting. Do not skip files.
Read ALL changed files completely before starting any review. Do not skip files.
## Step 2 — Read every persona (identity + checklist)
## Step 2 — Read Every Persona
Adopt each persona's voice from `.claude/personas/`; apply its review lens. For the SDD
personas, also re-read the matching `.specify/personas/` checklist — at PR time the same
checklist items are verified against the **code** rather than the spec.
Read all six persona files from `.claude/personas/`:
- `developer.md` → Felix Brandt
- `architect.md` → architect persona
- `tester.md` → tester persona
- `security_expert.md` → security persona
- `ui_expert.md` → UI/UX persona
- `devops.md` → DevOps persona
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) | PR-time focus |
|---|---|---|---|
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` | Traceability: every `REQ-NNN` implemented; RTM updated |
| Developer (Felix Brandt) | `developer.md` | `developer.md` | Clean code, layering, generate:api run, ErrorCode four-site |
| Tester | `tester.md` | — (uses identity) | Test quality: each REQ has a real failing-first test; edge cases; levels right |
| Security (Nora "NullX") | `security_expert.md` | `security.md` | authn/authz, IDOR, mass-assignment, `{@html}`, secrets/PII |
| DevOps | `devops.md` | `devops.md` | migration rollback, env vars, CI guards intact, artifact pin |
| UI/UX | `ui_expert.md` | `ui-ux.md` | states, i18n, a11y, design tokens |
| Architect | `architect.md` | `architect.md` | boundaries, ADR present for irreversible choices, no superseded-ADR violation |
## Step 3 — Write Each Review
## Step 3 — Write each review
For each persona, write a review that:
For each persona, fully adopt their identity, priorities, and review lens as described in their persona file. Write a review that:
- Opens with a one-line verdict: **✅ Approved**, **⚠️ Approved with concerns**, or **🚫 Changes requested**
- Lists concrete findings with file paths and line references; cite the constitution rule
(e.g. "violates §2.4 — `updatedBy` bound from request body") or the `REQ-NNN` at issue
- Distinguishes **blockers** (must fix) from **suggestions** (nice to have)
- **Requirements Engineer specifically** produces a traceability table — for each `REQ-NNN`:
is it implemented? is there a test? is `rtm.md` updated to `Done`? Any unimplemented or
untested REQ is a blocker. Any code behavior with no backing requirement is flagged
(scope creep — should it be a new REQ, or removed?).
- A constitution **Do-Not-Touch** violation (edited generated file, edited shipped migration,
edited an Accepted ADR, bumped the artifact action past v3, weakened a CI guard) is always
a blocker.
- Lists concrete findings with file paths and line references where relevant
- Distinguishes blockers (must fix) from suggestions (nice to have)
- Uses the persona's voice and priorities (e.g. Felix cares about TDD and clean code; the security expert checks for injection, auth, and data exposure; the architect checks layer boundaries and coupling)
- Stays focused — only comment on what the persona would actually care about
Format each comment in Markdown with a persona header, e.g.:
```
### 🔐 Security — PR Review
## 👨‍💻 Felix Brandt — Senior Fullstack Developer
**Verdict: ⚠️ Approved with concerns**
### Blockers
- `UserAvatarController.java:42` — REQ-009's 403 path has no test (constitution §2.8)
...
### Suggestions
- ...
...
```
## Step 4 — Post comments
## Step 4 — Post Comments
Post each persona's review as a **separate comment** via the Gitea MCP `issue_write` tool
(issues and PRs share the comment API). Post all personas; if one has nothing to flag, post a
brief "LGTM" naming what they checked.
Post each persona's review as a **separate comment** on the PR using the Gitea MCP `issue_write` tool (issues and PRs share the comment API in Gitea).
## Step 5 — Report back
Post all six comments. Do not skip any persona even if their domain has nothing to flag — in that case write a brief "LGTM" with a short explanation of what they checked.
Summarize to the user:
- Each persona's verdict and the overall verdict (worst-case wins: any "Changes requested" → overall "Changes requested")
- The full list of blockers, grouped by persona
- **Traceability status:** which `REQ-NNN` are implemented+tested vs. missing, and whether
`rtm.md` is in sync
- Any constitution Do-Not-Touch violations (called out explicitly)
## Step 5 — Report Back
After all comments are posted, summarize to the user:
- Which personas posted comments
- The overall verdict across all personas (worst-case wins: if any said "Changes requested", the overall is "Changes requested")
- A bullet list of the top blockers found (if any)

View File

@@ -1,40 +0,0 @@
---
name: "Bug"
about: "Something is broken. Describe user-facing impact, not the technical cause."
title: "<What breaks> when <trigger>"
labels:
- bug
assignees: []
---
<!--
Title format (COLLABORATING.md): "<What breaks> when <trigger>", e.g.
"Upload fails silently when file exceeds 50MB". Keep it focused — a bug is small and direct.
A failing test is written first, then the fix (red/green TDD).
-->
## What happens
<The observed broken behavior, from the user's perspective.>
## Expected
<What should happen instead.>
## Steps to reproduce
1.
2.
3.
## Originating requirement (if known)
<REQ-NNN + feature this regresses, from .specify/rtm.md — e.g. "REQ-008 (profile-picture-upload)". Helps target the failing test. Write "unknown" if not traceable.>
## Environment
<Browser / role / data state / deploy (local vs prod) as relevant.>
## Notes
<Logs, GlitchTip link, screenshots. Redact PII.>

View File

@@ -1,81 +0,0 @@
---
name: "Feature (SDD spec)"
about: "Spec-driven feature request. Fill in EARS requirements before implementation starts."
title: "As a <role> I want <capability> so <reason>"
labels:
- spec-required
- needs-review
assignees: []
---
<!--
This issue body IS the spec (issue-only — there is no committed spec.md). Every requirement
uses an EARS pattern + a REQ-NNN id. Reference: .specify/templates/feature-spec.md and the
worked example .specify/features/_example/. Delete the placeholder hints as you fill each section.
-->
## Context & Why
<Who needs this and why now (24 sentences). Link the constitution principle(s) this depends on: .specify/constitution.md>
## User Journey
<Plain-prose steps the user takes to get value, from the user's perspective. Anything not here is out of scope.>
## Requirements
<!-- One per line, each REQ-NNN + one EARS pattern. A mutating feature needs at least one Event-driven and one Unwanted-behavior requirement. -->
- **REQ-001** (Ubiquitous) — The `<component>` shall `<always-true behavior>`.
- **REQ-002** (Event-driven) — When `<trigger>`, the `<component>` shall `<response>`.
- **REQ-003** (State-driven) — While `<state>`, the `<component>` shall `<behavior>`.
- **REQ-004** (Optional-feature) — Where `<caller has Permission.X / flag set>`, the `<component>` shall `<behavior>`.
- **REQ-005** (Unwanted-behavior) — If `<undesired condition>`, then the `<component>` shall `<safe response / ErrorCode>`.
## Acceptance Criteria
<!-- One measurable criterion per REQ-NNN: numbers, limits, status codes — not adjectives. -->
- **REQ-001** — <measurable>.
- **REQ-002** — <measurable>.
## Out of Scope
- <The nearest tempting scope creep, named and excluded.>
## API / Contract Stub
<Inline OpenAPI stub (use .specify/templates/api-contract-stub.md as a writing aid). Name new paths/methods/status codes and the @RequirePermission on each mutating endpoint.>
## Data Model Changes
<Schema delta + next free Flyway V<n> (verify on disk) + rollback note. "none" if not applicable.>
## Security Considerations
<STRIDE categories touched (+ ASTRIDE if an AI agent/tool is involved). Link a threat-model.md if the attack surface is non-trivial.>
## Open Questions
<!-- Each item BLOCKS implementation until resolved. -->
- [ ] <question> — owner: <name>
## Traceability
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|---|---|---|---|
| REQ-001 | | | Planned |
<!-- Mirror these rows into .specify/rtm.md. -->
## Persona Review Results
| Persona | Status | Key Findings | Resolved |
|---|---|---|---|
| Requirements Engineer | PENDING | | |
| Developer | PENDING | | |
| Security | PENDING | | |
| DevOps | PENDING | | |
| UI/UX | PENDING | | |
| Architect | PENDING | | |

View File

@@ -161,147 +161,3 @@ jobs:
# without first re-evaluating ADR-011.
if: always()
run: rm -f .env.staging
npm-audit:
# Independent parallel job — a deploy failure cannot mask the audit signal
# and a clean audit cannot hide a broken deploy. Intentionally no `needs:`.
#
# Scans dev deps too (no --omit=dev), which is deliberately broader than the
# PR gate (ci.yml §Security audit) that uses --omit=dev. A nightly broader
# result is NOT a PR gate failure — it catches dev-tooling advisories (esbuild,
# Vite, etc.) early. See docs/infrastructure/ci-gitea.md §Nightly audit vs PR gate.
#
# Required Gitea secrets:
# NIGHTLY_AUDIT_TOKEN — PAT with issues scope only. An issues-only token
# means a leak via logs/process-args cannot push
# branches, open PRs, or read repo contents (ADR-041).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Assert jq is available
run: which jq || sudo apt-get install -y jq
- name: Run npm audit and file tracking issue on findings
# Never run under set -x — NIGHTLY_AUDIT_TOKEN in env would leak to logs.
env:
NIGHTLY_AUDIT_TOKEN: ${{ secrets.NIGHTLY_AUDIT_TOKEN }}
run: |
MARKER="Nightly npm audit: high-severity advisory"
GITEA_URL="${{ github.server_url }}"
REPO="${{ github.repository }}"
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
# --- Self-test (mirrors ci.yml §Assert pattern) ---
# Tests the exact jq test() call used in the dedupe step, before any
# API call, so a broken matcher fails loudly early rather than silently
# opening duplicate issues. Proves the regex only — create-vs-update
# decision is exercised by the workflow_dispatch AC.
echo "{\"title\": \"${MARKER}\"}" \
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
echo "Self-test passed."
# --- Run audit ---
# No npm ci — audit reads only the lockfile (no network, no install).
set +e
(cd frontend && npm audit --audit-level=high --json > /tmp/audit.json)
AUDIT_EXIT=$?
set -e
if [ "$AUDIT_EXIT" -ne 0 ]; then
# --- Build issue body with jq (never string-concat advisory text) ---
# Advisory overview/title text is registry-controlled; string-concat
# would be an injection/escaping vector into the API body. Truncate
# raw excerpt to 500 chars so a pathological overview can't produce
# a multi-MB PATCH body.
ISSUE_BODY=$(jq -r \
--arg run_url "$RUN_URL" \
'
(.vulnerabilities // {}) as $vulns |
($vulns | to_entries |
map(select(.value.severity == "high" or .value.severity == "critical")) |
map("- **" + .key + "** (" + .value.severity + ")") |
if length > 0 then join("\n") else "_See raw output for details._" end) as $pkg_list |
"## npm audit: high/critical advisories\n\n" + $pkg_list +
"\n\n**Run:** " + $run_url +
"\n\n<details><summary>Raw audit excerpt (first 500 chars)</summary>\n\n```\n" +
(tostring | .[0:500]) +
"\n```\n\n</details>"
' /tmp/audit.json)
# --- Dedupe: fetch open security issues, match by title marker ---
# Renovate vuln PRs also carry the "security" label, so >1 open
# "security" issue WILL occur. Title-match (not just label) ensures
# we deduplicate only our own tracking issue.
OPEN_ISSUES=$(curl -sf \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
MATCHED=$(echo "$OPEN_ISSUES" | jq \
--arg m "$MARKER" \
'[.[] | select(.title | test($m; "i"))] | sort_by(.created_at)')
MATCH_COUNT=$(echo "$MATCHED" | jq 'length')
if [ "$MATCH_COUNT" -gt 0 ]; then
# Patch the oldest matched issue (append run URL to body).
ISSUE_NUMBER=$(echo "$MATCHED" | jq -r '.[0].number')
EXISTING_BODY=$(echo "$MATCHED" | jq -r '.[0].body')
NEW_BODY=$(jq -n \
--arg existing "$EXISTING_BODY" \
--arg run_url "$RUN_URL" \
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
curl -sf -X PATCH \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
echo "Updated tracking issue #${ISSUE_NUMBER}"
else
# Closed prior issue that recurs → new issue (not reopened).
# A re-opened issue would obscure when the advisory was re-discovered.
PAYLOAD=$(jq -n \
--arg title "$MARKER" \
--arg body "$ISSUE_BODY" \
'{"title": $title, "body": $body}')
CREATED=$(curl -sf -X POST \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
echo "Opened new tracking issue #${NEW_NUMBER}"
# Labels are ignored on issue create in Gitea — add in a follow-up call.
LABEL_IDS=$(curl -sf \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
curl -sf -X POST \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"labels\": $LABEL_IDS}" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
fi
exit "$AUDIT_EXIT"
else
# --- Heartbeat: proves the job ran and found nothing ---
# "No issue created" is only meaningful evidence when paired with a
# visible positive signal. Without this, a never-ran job is
# indistinguishable from a clean run.
#
# $GITHUB_STEP_SUMMARY availability is unproven on this runner
# (act_runner populates it, but this is the first run to verify it).
# Guard before use so an unset variable does not fail the clean-path.
MSG="✅ npm audit clean $(date -u)"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "$MSG" >> "$GITHUB_STEP_SUMMARY"
fi
echo "$MSG"
fi

View File

@@ -1,44 +0,0 @@
name: Renovate
# Runs Renovate daily to surface newly-published advisories via OSV.dev
# (osvVulnerabilityAlerts) and open routine update PRs on a weekly batch
# schedule (see renovate.json §schedule). Security/vulnerability PRs are
# raised immediately regardless of the weekly schedule window.
#
# Required Gitea secrets (see docs/adr/041-renovate-runner-setup.md):
# RENOVATE_TOKEN — PAT with scopes: contents + pull_request + issues
# Belongs to a dedicated bot account. Branch protection
# on main must forbid this bot pushing directly.
#
# Platform config is injected via env vars below; the renovate.json in the
# repo root carries only dependency rules (no platform/endpoint/repos).
#
# Digest pin: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd
# corresponds to release v46.1.15. Update by bumping both the digest and the
# renovate-version when Renovate publishes a new release. Renovate itself
# will open a PR to bump this digest once it runs.
on:
schedule:
- cron: "0 3 * * *" # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Renovate
# Pinned by digest — this action holds contents+pull_request+issues
# scopes; an unpinned tag is a supply-chain risk (see ADR-041).
uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
with:
configurationFile: renovate.json
token: ${{ secrets.RENOVATE_TOKEN }}
renovate-version: "46.1.15"
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.raddatz.cloud
RENOVATE_REPOSITORIES: '["marcel/familienarchiv"]'
LOG_LEVEL: info

View File

@@ -1,169 +0,0 @@
name: SDD Gate
# Spec-Driven Development quality gate. Runs on PRs.
#
# This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed
# spec.md (see ADR-042). So CI cannot lint the spec text itself — instead it validates the SDD
# artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution.
#
# The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the
# workflow without CI immediately failing.
#
# TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`)
# once SDD adoption has settled — target: after the first 5 features have shipped through
# the workflow. Tracked in ADR-042.
on:
pull_request:
jobs:
# ─── RTM check ────────────────────────────────────────────────────────────────
# The Requirements Traceability Matrix is the one per-feature SDD artifact in git. Every
# data row must point at a Gitea issue (`#n`) and name at least one test. Warn otherwise.
# Pure awk — no external tooling. Columns: | REQ-ID | Summary | Issue | Feature | Impl | Test | Status |
rtm-check:
name: RTM Check
runs-on: ubuntu-latest
continue-on-error: true # TODO: remove to make blocking (see header)
steps:
- uses: actions/checkout@v4
- name: Validate .specify/rtm.md rows
shell: bash
run: |
set -uo pipefail
rtm=".specify/rtm.md"
test -f "$rtm" || { echo "::error::$rtm is missing"; exit 1; }
# Self-test: a good row passes, a row with an empty Issue or Test is flagged.
check_row() { awk -F'|' '{
issue=$4; test_col=$7;
gsub(/^[ \t]+|[ \t]+$/,"",issue); gsub(/^[ \t]+|[ \t]+$/,"",test_col);
if (issue !~ /#/ || test_col=="") exit 1; else exit 0 }'; }
echo '| REQ-001 | x | #42 | f | impl | SomeTest#works | Done |' | check_row \
|| { echo "FAIL: rtm-check self-test rejected a valid row"; exit 1; }
echo '| REQ-002 | x | | f | impl | | Planned |' | check_row \
&& { echo "FAIL: rtm-check self-test accepted an empty row"; exit 1; }
bad=0
while IFS= read -r line; do
echo "$line" | check_row || {
req=$(echo "$line" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2}')
echo "::warning file=$rtm::row $req is missing an Issue (#n) or a Test"
bad=$((bad+1))
}
done < <(grep -E '^\| REQ-[0-9]{3} ' "$rtm")
echo "$bad RTM row(s) incomplete (warning only)."
# ─── Contract validation ──────────────────────────────────────────────────────
# Validate any committed OpenAPI contract with Spectral (OpenAPI 3.1). REST stack — no
# GraphQL. Contracts are optional and ride a feature branch when present; the _example one
# is always linted. Skips cleanly when none changed.
contract-validate:
name: Contract Validate
runs-on: ubuntu-latest
continue-on-error: true # TODO: remove to make blocking (see header)
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '24'
# Cache the npm/npx download so Spectral isn't re-fetched every run. The key is pinned to
# the exact Spectral version below, so a version bump busts the cache deterministically.
- name: Cache Spectral (npm cache)
uses: actions/cache@v4
with:
path: ~/.npm
key: spectral-cli-6.16.0
restore-keys: spectral-cli-
- name: Lint changed OpenAPI contracts
shell: bash
env:
SPECTRAL: "@stoplight/spectral-cli@6.16.0" # pinned — keep in sync with the cache key above
run: |
set -uo pipefail
base="origin/${{ github.event.pull_request.base.ref }}"
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
# Any *.yaml under .specify/ or any file named like a contract.
changed="$(git diff --name-only "$base"...HEAD -- '.specify/**/*.yaml' '**/api-contract.yaml' '**/*.openapi.yaml' || true)"
if [ -z "$changed" ]; then
echo "No OpenAPI contract changed — nothing to validate."
exit 0
fi
rc=0
for f in $changed; do
[ -f "$f" ] || continue
echo "── spectral lint $f"
npx --yes "$SPECTRAL" lint "$f" || rc=1
done
exit $rc
# ─── Constitution change impact ───────────────────────────────────────────────
# When .specify/constitution.md is modified, list every file that references it (and so
# may need a Sync Impact update) and post it as a PR comment. Best-effort: if no token is
# available the list is only echoed to the log. This job is informational, never blocking.
constitution-diff:
name: Constitution Impact
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: List files referencing the constitution
id: impact
shell: bash
run: |
set -uo pipefail
base="origin/${{ github.event.pull_request.base.ref }}"
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
if ! git diff --name-only "$base"...HEAD -- '.specify/constitution.md' | grep -q .; then
echo "constitution.md not modified — skipping."
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Files referencing constitution.md (review for Sync Impact):"
grep -rIl --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=target \
-e 'constitution.md' -e 'constitution §' . \
| grep -v '^\./.specify/constitution.md$' | sort > /tmp/refs.txt || true
cat /tmp/refs.txt
{
echo "body<<EOF"
echo "### ⚠️ Constitution changed — Sync Impact review"
echo ""
echo "\`.specify/constitution.md\` was modified in this PR. Per its §6 Sync Impact rule, re-read and reconcile every file below, and confirm the semantic version bump:"
echo ""
while IFS= read -r line; do echo "- \`${line#./}\`"; done < /tmp/refs.txt
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Post PR comment (best-effort)
if: steps.impact.outputs.changed == 'true'
shell: bash
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
BODY: ${{ steps.impact.outputs.body }}
run: |
set -uo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "No token available — printing impact list to log only:"
echo "$BODY"
exit 0
fi
payload="$(jq -n --arg b "$BODY" '{body:$b}')"
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments" \
-d "$payload" >/dev/null \
&& echo "Posted Sync Impact comment to PR #${PR}." \
|| { echo "Comment POST failed (non-fatal); impact list:"; echo "$BODY"; }

View File

@@ -1,77 +0,0 @@
# AGENTS.md
Machine-readable rules for AI coding agents (Claude Code, Copilot, Cursor, …) working in
this repository. Read this on every invocation. These are **executable constraints**, not
aspirations. The full rationale lives in [constitution.md](./constitution.md) and the docs
it links — this file does not duplicate it, it points to it.
If anything here conflicts with the user's explicit instruction, the user wins. Otherwise,
constitution > this file > convenience.
---
## Stack & Versions
| Layer | Tech | Version |
|---|---|---|
| Backend | Spring Boot (Java, Maven, Jetty, JPA/Hibernate, Flyway, Spring Security, Session JDBC) | Boot 4.0.6 / Java 21 |
| API docs | springdoc-openapi (webmvc-ui), served at `/v3/api-docs` (dev profile only) | — |
| Frontend | SvelteKit / Svelte | 2.60 / 5.43 |
| Frontend lang/style | TypeScript / Tailwind CSS / Paraglide i18n (de/en/es) | TS 5.9 / TW 4.1 |
| API client | `openapi-fetch` + `openapi-typescript` (types generated from the live spec) | — |
| DB | PostgreSQL | 16 |
| Object storage | MinIO (S3-compatible) | — |
| Sidecars | `ocr-service`, `nlp-service` (Python / FastAPI) | Python 3.11 |
| Tests | JUnit + Mockito + `@WebMvcTest` + Testcontainers (backend); Vitest + `vitest-browser-svelte` + Playwright (frontend); Pytest (services) | — |
| Lint/format | ESLint 9 (+ `eslint-plugin-boundaries`) + Prettier; Semgrep (backend) | — |
| CI | Gitea Actions (`.gitea/workflows/`) | — |
App port `8080`; management port `8081`. Backend app id: `org.raddatz.familienarchiv` / `0.0.1-SNAPSHOT`.
## Architectural Constraints
- Controllers call services only — never a repository. (constitution §1.2)
- A service uses only its own domain's repository; reach other domains via their service. (constitution §1.3)
- A new backend domain goes in its own package AND is added to `ArchitectureTest`'s allow-lists in the same change. (constitution §1.7)
- Frontend cross-domain imports are allowed only where `frontend/eslint.config.js` permits; otherwise move shared code to `$lib/shared/`. (constitution §1.4)
- Never serialize a lazy-collection entity across the controller boundary — assemble a view in-transaction. (constitution §1.6 / ADR-036)
- `Person``AppUser`; do not add account guards to Person-domain operations. (constitution §1.5)
- Every `POST/PUT/PATCH/DELETE` endpoint has `@RequirePermission(Permission.X)`. Use the enum, never `@PreAuthorize`. (constitution §2.12.2)
- Throw only `DomainException.notFound/forbidden/conflict/internal()` from services, each with an `ErrorCode`. (CONTRIBUTING §Error handling)
- Set `createdBy`/`updatedBy` from the session principal in the service — never bind them from a request body. (constitution §2.4)
- Add an `@Schema(requiredMode = REQUIRED)` to every always-populated field. (constitution §3.5)
- Never introduce a new runtime dependency without an ADR in `Accepted` status. (constitution §5.1)
- Render untrusted text with `{...}`; never `{@html}` on user/import data. (constitution §2.5)
- Build dates from ISO strings with a `T12:00:00` suffix. (constitution §3.7)
## Workflow Rules
- Always write a failing test before implementation code; confirm it fails, then make it pass, then refactor. (constitution §3.1)
- Run only the specific test file/class locally — never the full suite (it crashes the machine); leave the full sweep to CI.
- Run `npm run generate:api` (in `frontend/`) after ANY backend model or endpoint change — most common cause of TS errors.
- Run `npm run lint` before every commit; a fresh frontend worktree needs `npm install` first or the pre-commit hook fails.
- When adding a new `ErrorCode`, update all four sites at once (constitution §3.6).
- One logical change per commit; reference the Gitea issue (`Closes #n` / `Refs #n`) on the last line.
- Create a git worktree for new issue work — never `git checkout -b` in the main repo while another branch has in-flight work. Avoid `+` in worktree/branch names (breaks vitest browser mode).
- Pull `main` as a separate explicit step before creating a branch.
- Track work as Gitea issues (`http://192.168.178.71:3005`, repo `marcel/familienarchiv`), not todo files.
- Verify ADR and Flyway migration numbers against disk before using one — parallel worktrees make issue-body numbers go stale.
## Do Not Touch
- Generated: `frontend/src/lib/generated/api.ts`, `frontend/src/lib/paraglide/`, `frontend/.svelte-kit/`, `frontend/build/`, `backend/target/`.
- Shipped Flyway migrations — add a new forward-only migration instead.
- An `Accepted` ADR — supersede it with a new one.
- `actions/(upload|download)-artifact` version — stays at `@v3` (ADR-014).
- CI guard steps — do not remove/weaken without an ADR.
- `main` — never commit directly; branch + PR only.
- Worktree copies (`familienarchiv-*`, `.worktrees/`) and `data/` — never commit.
## Spec-Driven Development
A feature's spec is its **Gitea issue body** — there is no committed `spec.md`. The issue's
EARS requirements (`REQ-NNN`) and acceptance criteria are the contract; each maps to a test,
traced in [`.specify/rtm.md`](./rtm.md) (`REQ-ID → issue # → test`). Read the issue before
implementing. The committed [`.specify/features/_example/`](./features/_example/) is a
template/reference showing the full artifact set, not a live feature. Full workflow:
[SPEC_DRIVEN_DEVELOPMENT.md](../SPEC_DRIVEN_DEVELOPMENT.md).

View File

@@ -1,25 +0,0 @@
# ADR archive — see `docs/adr/`
This project already keeps a mature, permanent ADR archive at
[`../../docs/adr/`](../../docs/adr/) (40+ records, format `NNN-kebab-title.md`). SDD does
**not** introduce a second archive — that would split the project's decision history in two.
## Where ADRs live
- **Project-wide decisions** → [`docs/adr/NNN-kebab-title.md`](../../docs/adr/). Use the
next free `NNN` (verify against the directory on disk — parallel worktrees make
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
- **The decision to adopt SDD itself** →
[`docs/adr/042-sdd-adoption.md`](../../docs/adr/042-sdd-adoption.md) (this is the
"ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence).
- **Feature-local decisions** that are only meaningful within one in-flight feature →
beside that feature's spec, e.g.
[`../features/_example/adr-001-avatars-reuse-archive-bucket.md`](../features/_example/adr-001-avatars-reuse-archive-bucket.md).
Promote one to `docs/adr/` if its reach turns out to be project-wide.
## Rules (unchanged from the existing convention)
- An ADR is **immutable once `Accepted`** — supersede it with a new, higher-numbered ADR;
set the old one's status to `Superseded by ADR-MMM`.
- Header style matches the existing archive: `# ADR-NNN — Title`, then
`**Status:** / **Date:** / **Issue:**`.

View File

@@ -1,80 +0,0 @@
# Familienarchiv Constitution
**Version:** v1.0.0
**Status:** Ratified
**Date:** 2026-06-13
**Adoption ADR:** [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)
> The non-negotiable rules of this project. Every spec, every PR, and every AI agent is
> bound by this document. Rules here are deliberately few and absolute — guidance and
> rationale live in [CLAUDE.md](../CLAUDE.md), [COLLABORATING.md](../COLLABORATING.md),
> [CODESTYLE.md](../CODESTYLE.md), [CONTRIBUTING.md](../CONTRIBUTING.md), and the ADR
> archive ([docs/adr/](../docs/adr/)). When this file conflicts with any of those, **this
> file wins** — open an ADR to change it.
>
> Versioning is semantic: **MAJOR** = a rule removed or weakened (existing code may now
> violate the constitution), **MINOR** = a rule added or tightened, **PATCH** = wording
> only. Any change requires the Sync Impact review in the last section.
---
## 1. Architecture Principles
1. The backend is organised package-by-domain under `org.raddatz.familienarchiv`; a new domain lives in its own package, never spread across layer packages.
2. Controllers never call repositories directly — a controller calls only services.
3. A service accesses only its own domain's repository; cross-domain data is fetched through the other domain's service, never its repository.
4. The frontend mirrors the backend domain split under `frontend/src/lib/<domain>/`, and cross-domain imports are allowed only where `frontend/eslint.config.js` (`boundaries/dependencies`) permits them.
5. A `Person` (historical subject) and an `AppUser` (login account) are distinct domains and never share an identity or an account guard.
6. Lazy-collection-bearing entities are never serialized across the controller boundary; the owning service assembles an explicit view inside the transaction (see [ADR-036](../docs/adr/036-geschichte-responses-are-views-not-entities.md)).
7. A new backend domain package is added to `ArchitectureTest`'s package allow-lists in the same change that introduces it.
8. Synchronous cross-domain side effects use in-transaction domain events, not direct service-to-service write calls (see [ADR-006](../docs/adr/006-synchronous-domain-events-in-transaction.md)).
## 2. Security Defaults
1. Every `POST`, `PUT`, `PATCH`, and `DELETE` endpoint carries `@RequirePermission(Permission.X)` — there is no unguarded mutating endpoint.
2. Authorization uses the typed `Permission` enum and `@RequirePermission`, never magic-string `@PreAuthorize`.
3. All user input is validated at the system boundary (controller / form action), and validation failures return a typed `ErrorCode`, never a raw exception.
4. Audit fields (`createdBy`/`updatedBy`) are set from the session principal inside the service and are never bound from a request body.
5. Untrusted text is rendered through Svelte's default `{...}` escaping; `{@html}` is never used on user- or import-derived strings.
6. Secrets are read only from environment variables (see `.env.example`); no secret, token, password, or DSN is ever committed to the repository or written to a log.
7. Logs never contain PII beyond a stable user/entity UUID — no names, email addresses, document contents, or transcription text.
8. Every state-mutating endpoint is covered by an Unwanted-behavior requirement (EARS `If`) describing the unauthenticated/unauthorized response.
9. A dependency security audit runs on every CI run (`npm audit --audit-level=high` frontend, Semgrep `.semgrep/security.yml` backend) and nightly; a `high` finding blocks merge.
## 3. Code Quality Rules
1. All new behavior is driven by a failing test written before the implementation (Red → Green → Refactor); a passing-on-first-run test proves nothing and is rejected.
2. KISS beats DRY — no premature abstraction; an abstraction is introduced only on the third real caller.
3. Each commit does exactly one logical thing and references its Gitea issue (`Closes #n` / `Refs #n`) on the last line of the body.
4. No backwards-compatibility shims are added for code that has no callers.
5. Every entity/DTO field the backend always populates carries `@Schema(requiredMode = REQUIRED)`, and `npm run generate:api` is run after any backend model or endpoint change.
6. A new `ErrorCode` is added in all four places at once: `ErrorCode.java`, `frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, and `messages/{de,en,es}.json`.
7. Dates built from an ISO date string append `T12:00:00` to avoid UTC off-by-one.
8. `npm run lint` (Prettier + ESLint, including the domain boundary rule) passes before every commit.
## 4. Do-Not-Touch List
1. Do not edit generated artifacts: `frontend/src/lib/generated/api.ts`, `frontend/src/lib/paraglide/`, `frontend/.svelte-kit/`, `frontend/build/`, `backend/target/`.
2. Do not edit an `Accepted` ADR — supersede it with a new, higher-numbered ADR.
3. Do not upgrade `actions/upload-artifact` / `download-artifact` past `@v3` (Gitea act_runner lacks the v4 protocol — [ADR-014](../docs/adr/014-upload-artifact-v3-pin.md)).
4. Do not remove or weaken a CI guard step (banned-pattern greps, self-tested regexes) without an ADR recording why.
5. Do not commit to `main` directly — all work flows through a branch and a PR.
6. Do not edit a Flyway migration that has shipped; add a new forward-only migration instead.
7. Do not commit the worktree copy directories (`familienarchiv-*`, `.worktrees/`) or `data/`.
## 5. Dependency Policy
1. A new runtime dependency (backend `pom.xml` or frontend `dependencies`) requires an ADR in `Accepted` status before it is merged.
2. A new dependency must be version-pinned in the manifest, and any exact pin (no caret) carries a comment stating why it cannot float (see the `@vitest/browser-playwright` pin).
3. Renovate manages dependency-update PRs; a major-version bump is treated as a feature requiring its own spec and review, not an auto-merge.
4. A dependency with an unresolved `high`+ advisory is not merged; it is pinned to a safe version or replaced.
## 6. Sync Impact
When this constitution changes, the author MUST, in the same PR:
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists.
3. Update any `.specify/templates/*` section that quotes a changed rule.
4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists.
5. Announce the version bump in the PR description so reviewers re-read the constitution before approving.

View File

@@ -1,46 +0,0 @@
# ADR-001 (feature-local) — Avatars reuse the archive bucket under an `avatars/` prefix
**Status:** Accepted
**Date:** 2026-06-13
**Issue:** #<example> (profile picture upload)
> **Feature-local ADR.** This decision is scoped to the avatar feature and lives with its
> spec. A decision with project-wide reach is promoted to the permanent archive at
> `docs/adr/` with the next free number. (For the worked example, it stays local.)
## Context
Avatars are small binary objects keyed per user. The project already runs MinIO with a
single archive bucket and a `FileService` abstraction used by document uploads. We must
decide where avatar bytes live without adding operational surface that the self-hosted
Compose deployment has to learn about.
## Decision
Store each avatar in the **existing archive bucket** under the deterministic key
`avatars/{userId}`, written and read through the existing `FileService`. No new bucket, no
new env var, no new Compose service or bucket-bootstrap step.
## Alternatives Considered
| Option | Pros | Cons | Reason rejected |
|---|---|---|---|
| Reuse archive bucket, `avatars/` prefix | No infra change; reuses `FileService`; idempotent overwrite | Mixes avatars with documents in one bucket | **Chosen** — least operational cost; prefix keeps them logically separate |
| Dedicated `avatars` bucket | Clean separation; independent lifecycle/policy | New bucket + bootstrap step + env var + Compose idempotency test | Operational overhead not justified for small, low-value objects |
| Store bytes in PostgreSQL (`bytea`) | One datastore; transactional with the row | Bloats the DB and backups; streaming images via JPA is awkward | Wrong tool; MinIO already exists for blobs |
| External CDN / object store | Offloads bandwidth | New third-party dependency + secret + ADR; conflicts with self-hosted goal | Contradicts the self-hosted infrastructure stance |
## Consequences
- No deployment change ships with this feature — only a Flyway column and code.
- Avatars and documents share a bucket; any future per-object lifecycle policy must filter
by the `avatars/` prefix.
- The deterministic key (`avatars/{userId}`, no random suffix) makes replace an overwrite,
so there is no orphan-cleanup obligation (REQ-001).
- If avatars later need independent retention or a public CDN, this ADR is superseded by a
project-wide ADR in `docs/adr/`.
## References
- [`./spec.md`](./spec.md), [`./design.md`](./design.md)
- [constitution §5 Dependency Policy](../../constitution.md#5-dependency-policy)

View File

@@ -1,140 +0,0 @@
openapi: 3.1.0
info:
title: Familienarchiv API — Profile picture upload
version: 0.0.1-SNAPSHOT
description: >
Design-time contract for the avatar feature (.specify/features/_example).
Source of truth once shipped is the generated /v3/api-docs.
servers:
- url: http://localhost:8080
description: Local backend (dev profile)
- url: https://archiv.raddatz.cloud
description: Production (behind Caddy)
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: SESSION
schemas:
ErrorResponse:
type: object
required: [code, message]
properties:
code:
type: string
example: AVATAR_TOO_LARGE
message:
type: string
UserProfileView:
type: object
required: [id, displayName]
properties:
id:
type: string
format: uuid
displayName:
type: string
avatarUrl:
type: [string, "null"]
description: Authenticated proxy path (/api/users/{id}/avatar) when an avatar exists, else null.
example: /api/users/3f1c.../avatar
security:
- cookieAuth: []
paths:
/api/users/me/avatar:
post:
summary: Upload or replace the current user's avatar
tags: [Users]
operationId: uploadMyAvatar
security:
- cookieAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: PNG or JPEG, max 2 MB.
responses:
'200':
description: Avatar stored; updated profile returned.
content:
application/json:
schema: { $ref: '#/components/schemas/UserProfileView' }
'400':
description: Unsupported type (UNSUPPORTED_FILE_TYPE) or too large (AVATAR_TOO_LARGE).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthenticated (UNAUTHORIZED).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
summary: Remove the current user's avatar
tags: [Users]
operationId: deleteMyAvatar
security:
- cookieAuth: []
responses:
'200':
description: Avatar removed; profile returned with avatarUrl null.
content:
application/json:
schema: { $ref: '#/components/schemas/UserProfileView' }
'401':
description: Unauthenticated (UNAUTHORIZED).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/api/users/{id}/avatar:
get:
summary: Stream a user's avatar image (authenticated proxy)
tags: [Users]
operationId: getUserAvatar
security:
- cookieAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: Image bytes.
content:
image/png: { schema: { type: string, format: binary } }
image/jpeg: { schema: { type: string, format: binary } }
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
'404': { description: User has no avatar, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
delete:
summary: Remove another user's avatar (admin only)
tags: [Users]
operationId: deleteUserAvatar
description: Requires Permission.ADMIN_USER (enforced by @RequirePermission on the controller).
security:
- cookieAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: Avatar removed.
content:
application/json:
schema: { $ref: '#/components/schemas/UserProfileView' }
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
'403':
description: Caller lacks ADMIN_USER (FORBIDDEN).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }

View File

@@ -1,76 +0,0 @@
# Persona Review Results — Profile picture upload
> Captured from the six persona spec reviews (the comments that, in a real feature, are
> posted on the Gitea issue). This is the worked example of what a completed review round
> looks like. All personas APPROVE; the two findings raised were folded into the spec
> before approval.
## Summary
| Persona | Verdict | Blocking FAILs | Notes |
|---|---|---|---|
| Requirements Engineer | APPROVE | none | — |
| Developer | APPROVE | none | — |
| Security | APPROVE | none (2 resolved) | See F-SEC-1, F-SEC-2 |
| DevOps | APPROVE | none | — |
| UI/UX | APPROVE | none (1 resolved) | See F-UX-1 |
| Architect | APPROVE | none (1 resolved) | See F-ARCH-1 |
---
## ### Security — Spec Review
| # | Item | Status | Note |
|---|---|---|---|
| 1 | All mutating endpoints have authn + authz `If` clauses | PASS | REQ-006 (401), REQ-009 (403) |
| 2 | Each mutating endpoint names least-privilege `Permission` | PASS | `me` = authenticated; `{id}` = ADMIN_USER |
| 3 | Audit fields server-set, forbidden in body | PASS | `avatarObjectKey` server-set (design.md) |
| 4 | IDOR surfaces addressed | PASS | `/{id}` gated by ADMIN_USER + ownership |
| 5 | Untrusted content rendered safely | PASS | image bytes via proxy + `nosniff` |
| 6 | Upload: type allow-list + size + bytes | PASS | REQ-007 (PNG/JPEG), REQ-008 (2 MB) |
| 7 | No entity internals leaked | PASS | `UserProfileView`, not `AppUser` |
| 8 | Conflicts → 409 not raw 500 | N/A | no optimistic-lock surface here |
| 9 | threat-model.md present & STRIDE-complete | PASS | [threat-model.md](./threat-model.md) |
| 10 | ASTRIDE if AI tool used | N/A | no AI agent |
| 11 | Secrets from env only | PASS | none introduced |
| 12 | Logs PII-free | PASS | user UUID only |
| 13 | New dependency has ADR + clean audit | N/A | no new dependency |
**F-SEC-1 (resolved):** initial draft exposed a public S3 URL for `avatarUrl`
information disclosure. Resolved: authenticated proxy `GET /api/users/{id}/avatar`.
**F-SEC-2 (resolved):** initial draft bound `avatarObjectKey` from the request body →
mass-assignment. Resolved: server-set only.
**Verdict: APPROVE.**
## ### UI/UX — Spec Review
| # | Item | Status | Note |
|---|---|---|---|
| 1 | Every interaction state described | PASS | idle/preview/uploading/error/done (T-10) |
| 2 | Strings via Paraglide i18n | PASS | T-8 |
| 3 | Reuses design tokens/components | PASS | placeholder uses existing initials pattern |
| 4 | Responsive per device split | PASS | control usable on phone + laptop |
| 5 | Errors via `getErrorMessage(code)` | PASS | UNSUPPORTED_FILE_TYPE / AVATAR_TOO_LARGE |
| 6 | Keyboard + screen-reader | PASS | labelled file input, alt text on image |
| 7 | Acceptance criteria measurable | PASS | sizes, status codes |
| 8 | E2E scenario per journey | PASS | T-12 |
| 9 | Confirmation for destructive action | PASS | remove asks to confirm |
| 10 | Safe rendering + image dims | PASS | fixed dims avoid layout shift |
| 11 | Live routes verified | PASS | `/profile`, `/users/[id]` exist |
| 12 | Token theming respected | PASS | semantic tokens |
**F-UX-1 (resolved):** no loading state in first draft → spinner during upload added (REQ-... covered by state set in T-10).
**Verdict: APPROVE.**
## ### Architect — Spec Review
Key items PASS. **F-ARCH-1 (resolved):** bucket choice was undocumented → captured in
[adr-001-avatars-reuse-archive-bucket.md](./adr-001-avatars-reuse-archive-bucket.md). No new
domain, no boundary crossing, Person/AppUser separation intact. **Verdict: APPROVE.**
## ### Requirements Engineer / Developer / DevOps — Spec Review
All checklist items PASS (see each persona's checklist in `.specify/personas/`). RE: 9 REQ
ids, all EARS-formed, every limit has an `If`. Developer: reuses `FileService`/`UserService`,
`AVATAR_TOO_LARGE` four-site update is T-1. DevOps: V78 forward-only + rollback note, no new
bucket/env var, idempotent overwrite. **All three: APPROVE.**

View File

@@ -1,63 +0,0 @@
# Design — Profile picture upload
> Companion to [`./spec.md`](./spec.md). The spec says *what*; this says *how*, and records
> the alternatives weighed for the non-obvious choices.
## Component overview
```
ProfileSettings.svelte ──► +page.server.ts (form action)
(preview, validate) │ POST /api/users/me/avatar (multipart)
UserAvatarController ── @RequirePermission(authenticated)
│ ownership/admin check for /{id}
UserService.setAvatar(userId, MultipartFile)
│ validate type+size → ErrorCode
├──► FileService.put("avatars/{userId}", bytes) (MinIO)
└──► userRepository.save(user.avatarObjectKey=key)
UserProfileView { …, avatarUrl }
```
Reads: `GET /api/users/{id}/avatar` streams the object through the authenticated API
(`FileService.get`), so no public S3 URL is ever exposed. `avatarUrl` in the view is simply
`/api/users/{id}/avatar` when a key exists, else `null`.
## Key decisions
| Decision | Choice | Why |
|---|---|---|
| Where avatars live | Existing archive bucket, `avatars/{userId}` prefix | No new bucket/env var/Compose change — see [ADR-001](./adr-001-avatars-reuse-archive-bucket.md). |
| URL exposure | Authenticated proxy endpoint, not a signed/public URL | Same auth surface as the rest of the API; no key leakage (Information disclosure). |
| Object key | Deterministic `avatars/{userId}` (no random suffix) | A new upload overwrites the old object — no orphan-cleanup job needed (REQ-001). |
| `avatarObjectKey` binding | Server-set in `UserService` only | Never bound from request body — prevents pointing a user's avatar at an arbitrary object (Tampering / CWE-639). |
| Validation site | `UserService`, boundary-only | Type + size checked once, at the service boundary, mapped to `ErrorCode` (constitution §2.3). |
## Layering & conventions
- Controller → `UserService` only; `UserService` owns `userRepository` and calls
`FileService` (its public API), never another domain's repository. (constitution §1.21.3)
- New `ErrorCode.AVATAR_TOO_LARGE` requires the four-site update (see `tasks.md` T-1).
- `UserProfileView.avatarUrl` is `String` (nullable) with `@Schema` describing the proxy
path; not marked `requiredMode = REQUIRED` because it is legitimately null (REQ-004).
- After backend changes: `npm run generate:api` regenerates `avatarUrl` into the TS types.
## Non-functional notes
- Size cap (2 MB, REQ-008) is enforced **before** the object touches MinIO — the multipart
is read into a bounded buffer; Spring's `spring.servlet.multipart.max-file-size` is set to
a matching ceiling so an oversized body is rejected at the container edge too.
- No N+1 risk: the profile view derives `avatarUrl` from the already-loaded `avatarObjectKey`
column; no extra query, no S3 round-trip on list/read paths.
- The proxy `GET` streams bytes (no full-buffer) and sets a short `Cache-Control` so an
updated avatar propagates quickly.
## Test strategy (maps to tasks.md)
| Level | What | Tooling |
|---|---|---|
| Unit | `UserService.setAvatar` validation + storage interactions | JUnit + Mockito (mock `FileService`) |
| Slice | controller auth, status codes, error codes | `@WebMvcTest` |
| E2E | upload → preview → confirm → avatar visible; remove → initials | Playwright |
| Component | initials placeholder when `avatarUrl` is null | `vitest-browser-svelte` (`*.svelte.spec.ts`) |

View File

@@ -1,118 +0,0 @@
# As a user I want to upload a profile picture so other family members recognise me
> **This is the canonical worked example for SDD in this repo.** It is fictional but
> realistic, chosen because no real avatar feature exists in the codebase. Use it as the
> reference shape for a real `spec.md`. Every section is filled — no placeholders.
## Context & Why
Readers and transcribers collaborate in threads and on document comments, but every user is
currently represented by initials only. Letting a user upload a small profile picture makes
the activity feed, comments, and the public user profile page (`/users/[id]`) more personal
and easier to scan — directly serving the family-archive product goal of feeling like a
shared family space, not a database.
Constitution principles this feature depends on:
- [§2 Security Defaults](../../constitution.md#2-security-defaults) — upload validation, permission gating, no PII in logs.
- [§1.3 services own their repository](../../constitution.md#1-architecture-principles) — avatar storage goes through `UserService` + `FileService`, not a controller.
- [§3.6 ErrorCode four-site rule](../../constitution.md#3-code-quality-rules) — introduces `AVATAR_TOO_LARGE`.
Related: builds on the existing `FileService` (MinIO) used by `Document` uploads.
## User Journey
A logged-in user opens their profile settings (`/profile`), clicks "Profilbild ändern",
selects a PNG or JPEG from their device, sees an instant preview, and confirms. The picture
replaces their initials everywhere their name appears. They can later remove it and fall
back to initials. An admin (with `ADMIN_USER`) can remove an inappropriate picture from
another user's account from the admin user view.
## Requirements
- **REQ-001** (Ubiquitous) — The user service shall store each profile picture as a single object in the existing archive bucket under the key `avatars/{userId}`, overwriting any previous object for that user.
- **REQ-002** (Event-driven) — When an authenticated user sends `POST /api/users/me/avatar` with a valid image, the user service shall store the image, set the user's `avatarObjectKey`, and return the updated profile view including a non-null `avatarUrl`.
- **REQ-003** (Event-driven) — When an authenticated user sends `DELETE /api/users/me/avatar`, the user service shall delete the stored object, clear `avatarObjectKey`, and return the profile view with `avatarUrl = null`.
- **REQ-004** (State-driven) — While a user has no stored avatar, the profile view for that user shall return `avatarUrl = null` and the frontend shall render the initials placeholder.
- **REQ-005** (Optional-feature) — Where the caller holds `Permission.ADMIN_USER`, the user service shall allow `DELETE /api/users/{id}/avatar` to remove another user's avatar.
- **REQ-006** (Unwanted-behavior) — If the request to any avatar endpoint is unauthenticated, then the system shall return `401` with `ErrorCode.UNAUTHORIZED` and store or delete nothing.
- **REQ-007** (Unwanted-behavior) — If the uploaded file's content type is not `image/png` or `image/jpeg`, then the user service shall return `400 ErrorCode.UNSUPPORTED_FILE_TYPE` and store nothing.
- **REQ-008** (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service shall return `400 ErrorCode.AVATAR_TOO_LARGE` and store nothing.
- **REQ-009** (Unwanted-behavior) — If a caller without `Permission.ADMIN_USER` targets another user's avatar via `/api/users/{id}/avatar`, then the system shall return `403 ErrorCode.FORBIDDEN` and modify nothing.
## Acceptance Criteria
- **REQ-001** — After a successful upload, exactly one object exists at `avatars/{userId}`; a second upload leaves exactly one object (no orphan), verified by a `FileService` interaction test.
- **REQ-002** — `POST /api/users/me/avatar` with a 100 KB PNG returns `200` and a body whose `avatarUrl` is a non-null string; the persisted `app_users.avatar_object_key` equals `avatars/{userId}`.
- **REQ-003** — `DELETE /api/users/me/avatar` returns `200`, the object is gone, and the response `avatarUrl` is `null`.
- **REQ-004** — `GET` profile view for a user with `avatar_object_key IS NULL` returns `avatarUrl: null`; the rendered component shows a 2-letter initials placeholder (Playwright).
- **REQ-005** — An `ADMIN_USER` caller deleting another user's avatar returns `200`; the target's `avatar_object_key` becomes `NULL`.
- **REQ-006** — An unauthenticated `POST`/`DELETE` returns `401`; bucket object count is unchanged.
- **REQ-007** — A `text/plain` or `application/pdf` upload returns `400 UNSUPPORTED_FILE_TYPE`; bucket object count is unchanged.
- **REQ-008** — A 2.1 MB PNG returns `400 AVATAR_TOO_LARGE`; bucket object count is unchanged.
- **REQ-009** — A non-admin caller targeting another user's id returns `403 FORBIDDEN`; the target's `avatar_object_key` is unchanged.
## Out of Scope
- Image cropping, resizing, or transformation — the client sends a final image; the server stores it verbatim within the size limit.
- Avatars for historical `Person` entities — this feature is for `AppUser` accounts only (Person ≠ AppUser).
- Gravatar / external avatar providers.
- Animated formats (GIF/WebP) — PNG and JPEG only in v1.
## API / Contract Stub
See [`./api-contract.yaml`](./api-contract.yaml). Endpoints:
`POST /api/users/me/avatar` (multipart), `DELETE /api/users/me/avatar`,
`DELETE /api/users/{id}/avatar` (ADMIN_USER). The profile view gains an optional
`avatarUrl: string | null`. All mutating endpoints carry `@RequirePermission``me`
endpoints require an authenticated session; the `{id}` delete requires `ADMIN_USER`.
## Data Model Changes
- Add nullable `avatar_object_key VARCHAR(512)` to `app_users`.
- Flyway `V78__add_app_user_avatar_object_key.sql` (next free number — verify against
`backend/src/main/resources/db/migration/` on disk before committing).
- **Rollback:** forward-only. Reverse manually with `ALTER TABLE app_users DROP COLUMN avatar_object_key;`. The MinIO `avatars/` objects are orphaned but harmless on rollback and can be pruned with `mc rm --recursive`.
## Security Considerations
STRIDE categories touched: **Tampering** (mass-assignment of `avatarObjectKey` if bound from
body), **Elevation of privilege** (a non-admin modifying another user's avatar — REQ-009),
**Denial of service** (oversized upload — REQ-008), **Information disclosure** (avatar URL
must not expose a signed key that bypasses auth). No AI agent involved, so ASTRIDE does not
apply. Full analysis: [`./threat-model.md`](./threat-model.md).
## Open Questions
> All resolved before implementation.
- [x] Public or signed avatar URL? — **Resolved:** served through an authenticated
`GET /api/users/{id}/avatar` proxy (same auth as the rest of the API), not a public S3 URL.
- [x] New bucket or reuse archive bucket? — **Resolved:** reuse the archive bucket under an
`avatars/` prefix; see [`./adr-001-avatars-reuse-archive-bucket.md`](./adr-001-avatars-reuse-archive-bucket.md).
## Traceability
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|---|---|---|---|
| REQ-001 | T-3 | `UserServiceAvatarTest#storesUnderUserKey`, `…#replaceLeavesNoOrphan` | Planned |
| REQ-002 | T-4 | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | T-5 | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | T-7 | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | T-6 | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | T-2 | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | T-2 | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | T-2 | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | T-6 | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
Mirrored in [`.specify/rtm.md`](../../rtm.md).
## Persona Review Results
| Persona | Status | Key Findings | Resolved |
|---|---|---|---|
| Requirements Engineer | APPROVE | All 9 REQ ids EARS-formed; every limit has an `If` clause. | — |
| Developer | APPROVE | Reuses `FileService`/`UserService`; `AVATAR_TOO_LARGE` four-site update listed (T-1). | — |
| Security | APPROVE | REQ-006/008/009 cover authn/DoS/EoP; `avatarObjectKey` server-set only (see threat model T-1). | Yes |
| DevOps | APPROVE | V78 forward-only with rollback note; no new bucket/env var. | — |
| UI/UX | APPROVE | Placeholder + loading/error states specified; strings via i18n (T-8). | — |
| Architect | APPROVE | Bucket-reuse decision captured in ADR-001; no new domain, no boundary crossing. | Yes |

View File

@@ -1,47 +0,0 @@
# Tasks — Profile picture upload
> Red/Green TDD order: each implementation task is preceded by the failing test that
> requires it. Task IDs are referenced from `spec.md` → Traceability and from `.specify/rtm.md`.
> Check off as work lands; reference the issue in each commit (`Refs #<n>`).
## Backend
- [ ] **T-1** Add `ErrorCode.AVATAR_TOO_LARGE` in all four sites at once: `ErrorCode.java`,
`frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`.
*(No new behavior yet — enables REQ-008's error.)* → covers REQ-008 (error plumbing)
- [ ] **T-2** `@WebMvcTest` `UserAvatarControllerTest`: write failing slice tests —
`unauthenticatedReturns401`, `rejectsNonImage` (400 UNSUPPORTED_FILE_TYPE),
`rejectsOversize` (400 AVATAR_TOO_LARGE). Then implement `UserAvatarController` +
`@RequirePermission` to green. → REQ-006, REQ-007, REQ-008
- [ ] **T-3** Unit `UserServiceAvatarTest`: failing tests `storesUnderUserKey`,
`replaceLeavesNoOrphan`, validation maps to `DomainException`. Then implement
`UserService.setAvatar`/`removeAvatar` (mock `FileService`) to green. → REQ-001, REQ-002, REQ-003
- [ ] **T-4** Flyway `V78__add_app_user_avatar_object_key.sql` (verify next free number on
disk) adding nullable `avatar_object_key VARCHAR(512)`; add the column + `@Schema` to
`AppUser` / `UserProfileView` (`avatarUrl` derived). Test: repository round-trip. → REQ-002
- [ ] **T-5** `deleteMyAvatar` controller test + impl (clears key, deletes object, returns
`avatarUrl: null`). → REQ-003
- [ ] **T-6** Admin path: failing tests `adminDeletesOthersAvatar` (200),
`nonAdminForbiddenOnOthers` (403). Implement ownership/`ADMIN_USER` check to green. → REQ-005, REQ-009
- [ ] **T-7** Authenticated proxy `getUserAvatar` streaming endpoint + `Content-Type` +
`X-Content-Type-Options: nosniff`; test 200 bytes / 404 when no avatar. → REQ-004 (view side)
- [ ] **T-A** Run `npm run generate:api` after T-4/T-7 so `avatarUrl` lands in `api.ts`.
## Frontend
- [ ] **T-8** i18n keys for the new strings in `messages/{de,en,es}.json` (button labels,
validation errors mapped via `getErrorMessage`). → REQ-007, REQ-008 (UX)
- [ ] **T-9** Component test `avatar-placeholder.svelte.spec.ts`: failing test asserting
initials render when `avatarUrl` is null; implement the placeholder. → REQ-004
- [ ] **T-10** `/profile` upload control: file picker, client-side type/size pre-check,
instant preview, confirm/remove. States: idle/preview/uploading/error/done. → REQ-002, REQ-003
- [ ] **T-11** Render avatar where names appear (comments, activity feed, `/users/[id]`),
falling back to the placeholder. → REQ-004
- [ ] **T-12** E2E `avatar.spec.ts`: upload → preview → confirm → avatar visible; remove →
initials return. → REQ-002, REQ-003, REQ-004
## Cross-cutting
- [ ] **T-13** Set `spring.servlet.multipart.max-file-size` to a 2 MB-matching ceiling so an
oversized body is rejected at the container edge (defense in depth for REQ-008).
- [ ] **T-14** Update `.specify/rtm.md` Status column to `Done` per REQ as each test goes green.

View File

@@ -1,45 +0,0 @@
# Threat Model — Profile picture upload
**Feature spec:** [./spec.md](./spec.md)
**Date:** 2026-06-13
**Author:** Security persona (worked example)
## Data Flow Diagram (text)
**Actors**
- Anonymous visitor (unauthenticated)
- Authenticated user (uploads their own avatar)
- Admin (`Permission.ADMIN_USER` — may remove others' avatars)
**Trust boundaries**
- TB-1: Browser ⇄ Caddy (public internet ⇄ DMZ)
- TB-2: Caddy ⇄ Backend `:8080` (DMZ ⇄ app)
- TB-3: Backend ⇄ MinIO + PostgreSQL (app ⇄ data plane)
**Data flows**
- F-1: Browser → [TB-1,TB-2] → `UserAvatarController` : multipart image
- F-2: `UserService` → [TB-3] → MinIO : object at `avatars/{userId}`
- F-3: `UserService` → [TB-3] → PostgreSQL : `app_users.avatar_object_key`
- F-4: Browser → [TB-1,TB-2,TB-3] → MinIO (via proxy GET) : image bytes
## STRIDE
| Threat Category | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|---|---|---|---|---|---|
| **S**poofing | F-1 | Unauthenticated caller uploads/deletes an avatar | Session auth required; `@RequirePermission` (REQ-006) | Low × Med | Mitigated |
| **T**ampering | F-3 | Caller sets `avatarObjectKey` via request body to point at an arbitrary stored object | `avatarObjectKey` is server-set in `UserService` only, never bound from body (CWE-639) | Med × High | Mitigated |
| **R**epudiation | F-2/F-3 | No record of who changed an avatar | Standard request logging by user UUID (no PII); admin deletions auditable via existing logs | Low × Low | Accepted |
| **I**nformation disclosure | F-4 | A public/signed S3 URL would let anyone fetch any avatar without auth | Avatars served only through the authenticated proxy `GET /api/users/{id}/avatar`; no public URL | Med × Med | Mitigated |
| **I**nformation disclosure | F-1 | Malicious file (polyglot) served back with a sniffed content type → stored XSS | Store with a fixed `image/png`/`image/jpeg` content type; proxy sets `Content-Type` + `X-Content-Type-Options: nosniff`; only PNG/JPEG accepted (REQ-007) | Low × High | Mitigated |
| **D**enial of service | F-1/F-2 | Oversized or many uploads exhaust storage/memory | 2 MB cap enforced before MinIO write + `multipart.max-file-size` ceiling (REQ-008); deterministic key means one object per user | Med × Med | Mitigated |
| **E**levation of privilege | F-1 | Non-admin removes/replaces another user's avatar via `/{id}` | Ownership check; `ADMIN_USER` required for `/{id}` (REQ-005/REQ-009, 403) | Low × Med | Mitigated |
## ASTRIDE
Not applicable — this feature invokes no AI agent, model, or tool.
## Residual Risk
- **Repudiation (Accepted):** avatar changes are not written to a dedicated audit table.
Accepted because the asset is low-value (a self-chosen picture) and request logs already
attribute the action to a user UUID. Revisit if avatars ever become trust signals.

View File

@@ -1,40 +0,0 @@
# Persona — Architect (spec review)
> Concise spec-review checklist. Full character persona:
> [`.claude/personas/architect.md`](../../.claude/personas/architect.md). This file gates a
> `spec.md` and its `design.md`/ADRs for systemic fit and long-term consequence.
## Role summary
I check that a feature fits the system's domain boundaries and decision history, and that
any irreversible choice it makes is captured in an ADR before code is written. I block specs
that quietly contradict an Accepted ADR, blur a domain boundary, or bake in a decision with
no recorded rationale.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does the feature respect the package-by-domain structure — new code in the right domain, no logic smeared across layer packages?
2. Does it honor the layering rule and the frontend boundary rule, or does it justify and record any new cross-domain edge?
3. Does any irreversible or contentious decision (new dependency, new domain, data-model shape, response-as-view vs entity, sync vs async side effect) have an ADR in `Proposed`/`Accepted` status under `docs/adr/`?
4. Does the spec contradict any existing Accepted ADR — and if a change is intended, does it **supersede** that ADR rather than silently diverge?
5. Is the ADR number the next free one verified against `docs/adr/` on disk?
6. Does the design reuse an established pattern (in-transaction views per ADR-036, domain events per ADR-006, DatePrecision sharing per ADR-039/040) instead of a novel mechanism for a solved problem?
7. Are domain terms used per [docs/GLOSSARY.md](../../docs/GLOSSARY.md), keeping the ubiquitous language consistent?
8. Is the blast radius bounded — does the change avoid forcing edits across unrelated domains, or is the coupling explicitly justified?
9. Does the data model choose the right precision/constraint level deliberately (e.g. NOT NULL audit fields, CHECK constraints) rather than by default, and is the choice recorded?
10. Does the spec keep `Person`/`AppUser` (and other established separations) distinct?
11. Are non-functional consequences (performance of the lazy-fetch path, N+1 risk, index needs) named in `design.md`?
12. Does `design.md` list the alternatives considered and why they were rejected, not just the chosen path?
## EARS patterns to watch for
- **Ubiquitous** requirements (`The <system> shall <invariant>`) encode architectural invariants — confirm each invariant is enforced at the right layer (DB CHECK, service guard, or type) and not merely asserted in prose.
- **Optional-feature** requirements signal a new seam/extension point — verify it does not become an unbounded plugin surface without an ADR.
- Watch for requirements that imply a second source of truth for data that already has an owning domain.
## Output format
A Gitea comment titled **`### Architect — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
blocking `FAIL` numbers and, for any decision lacking one, the specific ADR that must be
written before implementation.

View File

@@ -1,39 +0,0 @@
# Persona — Developer (spec review)
> Concise spec-review checklist. Full character persona:
> [`.claude/personas/developer.md`](../../.claude/personas/developer.md). This file gates a
> `spec.md` for implementability against the real codebase.
## Role summary
I check that a spec can actually be built in *this* codebase without fighting its
architecture: that it reuses existing services, layers, and error machinery, and that its
requirements decompose cleanly into red/green TDD tasks. I block specs that invent parallel
structures or hand-wave the hard integration points.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does the spec reference existing service interfaces (e.g. `DocumentService`, `FileService`, `UserService`) rather than inventing new ones inconsistent with the current layer structure?
2. Does it respect the layering rule — no requirement implies a controller touching a repository or a service reaching into another domain's repository?
3. If it adds a backend domain, does it commit to adding the package to `ArchitectureTest`'s allow-lists?
4. Are new error conditions expressed as named `ErrorCode`s, with the four-site update (`ErrorCode.java`, `errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`) called out as tasks?
5. Does every entity/DTO field the spec adds get `@Schema(requiredMode = REQUIRED)` where always-populated, and is `npm run generate:api` listed as a task after backend changes?
6. Are frontend changes inside the correct `$lib/<domain>/` boundary, with any cross-domain import either pre-allowed in `eslint.config.js` or flagged for an explicit allow-entry?
7. Does each `REQ-NNN` imply a concrete test at the right level (unit / `@WebMvcTest` slice / Playwright E2E per COLLABORATING.md's table) — i.e. is it specified concretely enough to write that test?
8. Is lazy-loading handled — does any returned entity with a lazy collection get a view (ADR-036) instead of being serialized raw?
9. Does the design avoid premature abstraction (KISS over DRY) — no new base class/util introduced before a third caller exists?
10. Are data-model changes expressed as a single forward-only Flyway migration with the next free `V<n>` number verified against disk?
11. Does the spec avoid backwards-compat shims for code paths that have no existing callers?
12. Are the requirements decomposable into a red/green-ordered task list — each behavior small enough that a failing test can precede its implementation?
## EARS patterns to watch for
- **Event-driven** requirements must name the exact endpoint/method so the test target is unambiguous (`When POST /api/users/{id}/avatar receives a valid image, the user service shall …`).
- **Unwanted-behavior** requirements are the ones that become `@WebMvcTest` error-path cases — flag any that lack a stated `ErrorCode` and HTTP status.
- **Optional-feature** (`Where …`) requirements map to a `@RequirePermission` gate — confirm the permission already exists or is added.
## Output format
A Gitea comment titled **`### Developer — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing the
blocking `FAIL` numbers and the single most important integration risk in one sentence.

View File

@@ -1,39 +0,0 @@
# Persona — DevOps (spec review)
> Concise spec-review checklist. Full character persona:
> [`.claude/personas/devops.md`](../../.claude/personas/devops.md). This file gates a
> `spec.md` for deployability, migration safety, and CI/observability impact.
## Role summary
I check that a feature can ship to the self-hosted Gitea-Actions / Docker-Compose
environment without breaking deploys, migrations, or observability. I block specs that add
a migration with no rollback story, a new env var nobody documented, or a CI step that the
act_runner cannot execute.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does the spec include a rollback strategy for any database migration it introduces (forward-only `V<n>` plus the manual DDL to reverse it, or an explicit "no rollback, forward-fix only" statement)?
2. Is the Flyway migration number the next free `V<n>` verified against disk, not copied from a stale issue body?
3. Are all new configuration values introduced as documented env vars (added to `.env.example`) and read via env, never hard-coded?
4. Does any new CI step avoid `actions/(upload|download)-artifact@v4+` and other features the Gitea `act_runner` does not support?
5. If the spec adds a CI guard, is it self-testing (the regex proves it catches the bad form and ignores the good form), matching the existing guard style?
6. Does the feature keep the management port (`8081`) / app port (`8080`) separation intact, and not require Caddy to proxy `/actuator/*`?
7. Are new dependencies pinned, and does the change keep `npm audit --audit-level=high` and Semgrep green?
8. Does a new external service or sidecar come with a healthcheck and a documented Compose entry, and is bucket/bootstrap logic idempotent (re-deploy must not fail)?
9. Are new metrics/logs/traces routed through the existing observability stack (Prometheus scrape, Promtail/Loki, Tempo, GlitchTip) rather than a new ad-hoc channel?
10. Does logging added by the feature stay PII-free and structured (JSON), consistent with the existing log pipeline?
11. Is the feature backwards-compatible across a rolling deploy, or does the spec state the required downtime/ordering (migrate-then-deploy)?
12. Does the spec avoid committing secrets, and does any composite-action secret flow follow the unquoted-heredoc env convention (ADR-029)?
## EARS patterns to watch for
- **State-driven** (`While a migration is in progress, the system shall …`) and **Unwanted-behavior** (`If the OCR service is unavailable, then the system shall return OCR_SERVICE_UNAVAILABLE`) requirements encode operational resilience — flag mutating/processing features that lack them.
- **Optional-feature** (`Where the observability stack is enabled …`) requirements gate optional infra — confirm the feature degrades cleanly when it is off.
## Output format
A Gitea comment titled **`### DevOps — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
blocking `FAIL` numbers, with the migration/rollback line called out explicitly when
relevant.

View File

@@ -1,43 +0,0 @@
# Persona — Requirements Engineer (spec review)
> Concise spec-review checklist. The full character persona (used for issue/PR review via
> the `review-issue` / `review-pr` skills) lives at
> [`.claude/personas/req_engineer.md`](../../.claude/personas/req_engineer.md). This file is
> scoped to one job: gate a `spec.md` before implementation starts.
## Role summary
I own requirement quality: every requirement must be atomic, testable, uniquely identified,
and written in EARS so an engineer and an AI agent read it the same way. I block specs that
are ambiguous, unmeasurable, or untraceable — vague requirements become vague code.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does every requirement have a unique zero-padded `REQ-NNN` ID, scoped to this feature?
2. Is every requirement written in one of the five EARS patterns (no free-prose "shall" sentences)?
3. Is each requirement atomic — exactly one testable behavior, no "and"-joined clauses hiding two requirements?
4. Does every requirement name a concrete system actor (e.g. `the document service`, `the upload form`) rather than a vague "system"?
5. Does each `REQ-NNN` have at least one matching, **measurable** acceptance criterion (numbers/limits, not adjectives like "fast" or "user-friendly")?
6. Are all five EARS patterns considered, and is each used where appropriate (not every requirement forced into Ubiquitous)?
7. Is there an Unwanted-behavior (`If …`) requirement for every error, limit, and rejected input the happy path implies?
8. Does the `## Out of Scope` section explicitly fence off the nearest tempting scope creep?
9. Are all `## Open Questions` resolved (or explicitly deferred with an owner) — none left as silent blockers?
10. Does the spec link the constitution principle(s) it depends on in `## Context & Why`?
11. Is every `REQ-NNN` present in `.specify/rtm.md` with a Feature, Test, and Status column filled (even if Status = Planned)?
12. Does the spec reuse existing domain vocabulary from [docs/GLOSSARY.md](../../docs/GLOSSARY.md) (e.g. Person vs AppUser, Chronik vs Aktivität) rather than inventing terms?
13. Are the User Journey and E2E Scenarios (per COLLABORATING.md) present and consistent with the EARS requirements?
## EARS patterns to watch for (common violations)
- **Ubiquitous** — `The <system> shall <behavior>.` Violation: an invariant written as prose with no "shall".
- **Event-driven** — `When <trigger>, the <system> shall <behavior>.` Violation: a trigger described but the response left implicit.
- **State-driven** — `While <state>, the <system> shall <behavior>.` Violation: a state precondition buried inside an Event-driven clause.
- **Optional-feature** — `Where <feature is present>, the <system> shall <behavior>.` Violation: a permission-/flag-gated behavior written as Ubiquitous, so it appears mandatory.
- **Unwanted-behavior** — `If <undesired condition>, then the <system> shall <response>.` Violation: missing entirely — the single most common gap. Every limit and rejected input needs one.
## Output format
A Gitea comment titled **`### Requirements Engineer — Spec Review`** containing the
checklist as a table `| # | Item | Status | Note |` with `PASS` / `FAIL` / `QUESTION` per
row, then a short verdict line: `Verdict: APPROVE` or `Verdict: CHANGES REQUESTED` with the
blocking `FAIL` numbers listed.

View File

@@ -1,42 +0,0 @@
# Persona — Security (spec review)
> Concise spec-review checklist. Full character persona (Nora "NullX" Steiner):
> [`.claude/personas/security_expert.md`](../../.claude/personas/security_expert.md). This
> file gates a `spec.md` and its `threat-model.md` before implementation.
## Role summary
I read every spec adversarially: I assume the requirement will be hit by an unauthenticated
attacker, a logged-in user attacking another user's data, and malicious input. I block specs
whose mutating endpoints, file handling, or audit trails leave a hole that the happy-path
requirements never mention.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Are **all** state-mutating endpoints (`POST/PUT/PATCH/DELETE`) covered by an Unwanted-behavior EARS clause for unauthenticated **and** unauthorized access, each naming the `Permission` and the response code?
2. Does every mutating endpoint name the `@RequirePermission(Permission.X)` it will carry — and is that permission the least privilege that works?
3. Are audit fields (`createdBy`/`updatedBy`) specified as server-set from the session principal, with an explicit requirement forbidding them in the request body (mass-assignment / authorship-forgery, CWE-639)?
4. Is every IDOR surface addressed — does fetching/mutating a child resource verify it belongs to the caller's accessible parent (e.g. JourneyItem → Geschichte), with a requirement and a test?
5. Is all untrusted text (user input, OCR/import-derived) specified to render via default escaping, never `{@html}` (CWE-79)?
6. For file uploads: are content-type allow-list, size limit, and magic-byte/extension validation specified as requirements with concrete numbers and an `ErrorCode`?
7. Does the spec avoid leaking entity internals (email, password hash, group graph) in any response — i.e. does it use a view, not a raw `AppUser`/entity?
8. Are concurrency conflicts (optimistic locking) specified to surface as `conflict()` (409), never a raw 500 exposing Hibernate internals (CWE-209)?
9. Does the `threat-model.md` exist and cover the relevant STRIDE categories for each new data flow and trust boundary?
10. If the feature invokes an AI agent/tool (OCR/NLP/LLM), does the threat model cover the ASTRIDE extensions (prompt injection, context poisoning, unsafe tool invocation, reasoning subversion)?
11. Are secrets (tokens, DSNs, passwords) sourced only from env vars, with none introduced into the repo, config, or logs?
12. Does logging for this feature exclude PII beyond a stable UUID (no names, emails, document/transcription content)?
13. Does a new runtime dependency (if any) have an ADR and a clean `npm audit` / Semgrep status?
## EARS patterns to watch for
- The **Unwanted-behavior** pattern (`If <attacker condition>, then the <system> shall <safe response>`) is *the* security pattern. Every auth, authz, validation, and limit case must appear as one. A spec with zero `If` requirements on a mutating endpoint is an automatic `FAIL`.
- **Optional-feature** (`Where the caller has Permission.X …`) requirements encode the authorization model — verify the gate is on the *write*, not just the read.
- Watch for **Ubiquitous** requirements that quietly assume trust ("The system shall store the uploaded file") with no companion `If` clause validating it first.
## Output format
A Gitea comment titled **`### Security — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, each `FAIL` tagged with its CWE where applicable, then
`Verdict: APPROVE` / `CHANGES REQUESTED` listing blocking `FAIL` numbers. Security `FAIL`s
are hard blockers — a spec does not proceed until each is resolved or risk-accepted in the
threat model.

View File

@@ -1,39 +0,0 @@
# Persona — UI/UX (spec review)
> Concise spec-review checklist. Full character persona:
> [`.claude/personas/ui_expert.md`](../../.claude/personas/ui_expert.md). This file gates a
> `spec.md` for user-facing features against the project's design system and audience split.
## Role summary
I check that a user-facing feature is usable by *this* audience — older transcribers on
laptops/tablets and younger readers on phones — and that it uses the established design
tokens, components, and i18n rather than reinventing them. I block specs whose UI is
described in adjectives instead of states, or that ignore accessibility and responsiveness.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does the spec describe every interaction **state** (loading, empty, error, success, disabled), not just the happy path?
2. Are user-facing strings specified to go through Paraglide i18n with keys added to `messages/{de,en,es}.json` — no hard-coded German/English literals?
3. Does it reuse the established component library and patterns (`BackButton`, the card pattern, `brand-navy`/`brand-mint` tokens, `font-serif`/`font-sans`) rather than introducing new one-off styles?
4. Is the responsive behavior specified per the device split — Critical for the reader/phone path, at least Minor for the author/laptop path — with concrete breakpoints, not "responsive"?
5. Are error states mapped to `getErrorMessage(code)` output so the user sees a localized message, never a raw code or stack?
6. Is every interactive element keyboard-reachable and screen-reader-labeled (the project runs `@axe-core/playwright`)?
7. Are acceptance criteria measurable (e.g. "image preview appears within 1 of selection", "tap target ≥ 44px"), not adjectival ("looks clean")?
8. Does the spec define an E2E Playwright scenario (per COLLABORATING.md) for each primary user journey step?
9. For destructive or irreversible actions, is a confirmation/undo affordance specified?
10. Does any uploaded/derived content render through default escaping (no `{@html}`), and are images given alt text / dimensions to avoid layout shift?
11. Does the feature respect existing navigation (live DOM nav, real routes — verify route names against the running app, since CLAUDE.md route lists can be stale)?
12. Is dark-mode / token theming respected (uses semantic tokens like `bg-surface`/`text-ink-3`, not raw palette constants)?
## EARS patterns to watch for
- **State-driven** (`While the upload is in progress, the upload form shall show a progress indicator`) requirements capture UI states — a UI spec with no `While` requirements usually means the loading/disabled states were forgotten.
- **Event-driven** (`When the user selects an image, the form shall render a preview`) requirements map directly to Playwright steps — confirm each has a measurable acceptance criterion.
- **Unwanted-behavior** (`If the selected file exceeds the size limit, then the form shall show a localized error and not upload`) requirements cover client-side validation feedback.
## Output format
A Gitea comment titled **`### UI/UX — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
blocking `FAIL` numbers and the single biggest usability/accessibility gap in one sentence.

View File

@@ -1,125 +0,0 @@
# Requirements Traceability Matrix (RTM)
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec
> itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this
> matrix is the part of the spec that *is* committed: it links each requirement to its issue,
> the code that implements it, and the test(s) that prove it — so any requirement traces end
> to end, and any orphan (a requirement with no test) is visible on `main`.
## How to update
1. When a feature's issue is approved (via `/review-issue`), add one row per `REQ-NNN` with the
`Issue` set to the Gitea issue number and `Status: Planned`. Commit these rows on the feature
branch (they merge with the feature's PR).
2. As tasks land, fill `Implementation File(s)` + `Test(s)` and flip `Status`
`In progress``Done`.
3. `REQ-ID`s are **scoped per feature**, so always read them together with the `Issue` column —
`REQ-001` for issue #142 is not `REQ-001` for issue #150.
4. The `sdd-gate.yml` CI job (`rtm-check`) warns (non-blocking, for now) when a row is missing
its `Issue` or `Test(s)`. It flips to blocking once adoption settles (see the workflow's TODO).
## Status legend
`Planned` · `In progress` · `Done` · `Deferred`
## Matrix
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|---|---|---|---|---|---|---|
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
| REQ-003 | `UNKNOWN``null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done |
| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done |
| REQ-009 | every derived event: derived=true, type=PERSONAL, non-null derivedType, non-UUID id | #776 | derive-person-life-events | `timeline/TimelineEventService` | structural invariants asserted inline in every event test | Done |
| REQ-010 | every derived event: non-null non-blank primaryPersonName; Heirat also non-null non-blank relatedPersonName | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_with_displayname_for_both_spouses` | Done |
| REQ-011 | exactly one call to findAllFamilyMembers() and one to findAllSpouseEdges() — no N+1 | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents`, `relationship/RelationshipService#findAllSpouseEdges` | test structure: only batch-fetch mocks used (no per-person stubs) | Done |
| REQ-012 | familyMember=false persons excluded from Geburt/Tod assembly | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` (via PersonService.findAllFamilyMembers) | `DerivedEventsAssemblyTest#should_exclude_non_family_member_persons_from_derived_events` | Done |
| REQ-013 | SPOUSE_OF edge with one non-family-member spouse still emits 1 MARRIAGE event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_when_one_spouse_is_not_family_member` | Done |
| REQ-014 | empty family-member list → empty result, no error | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | `DerivedEventsAssemblyTest#should_emit_zero_events_when_no_family_members` | Done |
| REQ-015 | assembleDerivedEvents() annotated @Transactional(readOnly=true) | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | code-review check (annotation visible in source) | Done |
| REQ-016 | no PII (names, dates) in log statements — only aggregate counts | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | log-audit: single log.debug with result.size() and persons.size() only | Done |
| REQ-001 | GET /api/timeline requires READ_ALL permission; 401 unauthenticated, 403 wrong permission | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated`, `#returns_403_when_authenticated_without_read_all`, `#returns_200_with_read_all_permission` | Done |
| REQ-002 | within-band sort: precision rank desc (DAY>MONTH>SEASON>YEAR>APPROX), then date asc, then title alpha, then id tiebreak | #777 | timeline-assembly | `timeline/TimelineService#WITHIN_BAND_ORDER` | `TimelineServiceTest#within_band_order_day_precision_sorts_before_year`, `#within_band_order_same_precision_and_date_sorts_alphabetically`, `#within_band_order_same_title_uses_document_id_as_tiebreak`, `#test5_day_precision_sorts_before_year_in_same_year_band`, `#test6_same_precision_same_date_sorted_alphabetically_by_title` | Done |
| REQ-003 | null eventDate OR UNKNOWN precision → undated bucket (never in a year band) | #777 | timeline-assembly | `timeline/TimelineService#bucketByYear` | `TimelineServiceTest#test3a_null_date_letter_goes_to_undated`, `#test3b_unknown_precision_letter_goes_to_undated` | Done |
| REQ-004 | RANGE events placed in start-year band only; null eventDateEnd does not crash; start year outside [fromYear,toYear] → excluded | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents` | `TimelineServiceTest#test7a_range_event_placed_only_in_start_year_band`, `#test7b_range_event_with_null_eventDateEnd_does_not_crash`, `#test8_range_event_excluded_when_start_year_before_fromYear`, `#test15_range_event_start_year_equal_to_fromYear_is_included` | Done |
| REQ-005 | null sender and null senderText on a document → senderName="" in the TimelineEntryDTO | #777 | timeline-assembly | `timeline/TimelineService#toLetterEntry` | `TimelineServiceTest#test4_letter_with_null_sender_and_null_senderText_produces_empty_names` | Done |
| REQ-006 | personId filter: include document when personId is sender OR receiver | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
| REQ-007 | documents domain letters always included (no type filter applied to LETTER kind) | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments`, `#assemble` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events`, `#test1_empty_archive_returns_empty_dto`, `#test2_one_year_letter_returns_one_year_band` | Done |
| REQ-008 | personId filter dedup: sender+receiver same person → document appears exactly once | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
| REQ-009 | type filter applies to events only; letters (LETTER kind) always pass | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents`, `#assembleDerivedEventsLayer` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events` | Done |
| REQ-010 | generation filter: PersonService.getPersonsByGeneration(N) used to build person-id set; filters all three layers | #777 | timeline-assembly | `timeline/TimelineService#assemble`, `person/PersonService#getPersonsByGeneration`, `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test9b_generation_filter_includes_letter_when_sender_matches_generation`, `TimelineServiceIntegrationTest#findByGeneration_returns_matching_persons`, `#findByGeneration_returns_empty_list_not_npe_when_no_match`, `#findByGeneration_does_not_return_null_generation_persons` | Done |
| REQ-011 | fromYear/toYear inclusive year-range filter; single-year window (fromYear==toYear); one-sided filter (fromYear only) | #777 | timeline-assembly | `timeline/TimelineService#passesYearFilter` | `TimelineServiceTest#test9c_fromYear_toYear_inclusive_single_year_window`, `#test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards` | Done |
| REQ-012 | combined filters AND logic — entry must pass all active filters | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test10_adversarial_and_logic_neither_event_passes_both_filters`, `#test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match` | Done |
| REQ-013 | empty archive (no events, no persons, no documents) → TimelineDTO { years=[], undated=[] } | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test1_empty_archive_returns_empty_dto` | Done |
| REQ-014 | unauthenticated request → 401 Unauthorized | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated` | Done |
| REQ-015 | authenticated without READ_ALL → 403 Forbidden | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_403_when_authenticated_without_read_all` | Done |
| REQ-016 | fromYear > toYear → 400 Bad Request | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#fromYear_greater_than_toYear_throws_bad_request`, `TimelineControllerTest#returns_400_when_fromYear_greater_than_toYear` | Done |
| REQ-017 | generation < 0 → 400 Bad Request (@Min(0) on controller param) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_when_generation_is_negative` | Done |
| REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done |
| REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done |
| REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done |
| REQ-001 | `/zeitstrahl` renders the global timeline for authenticated users, personId undefined | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts`, `+page.svelte` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
| REQ-002 | server-load fetches GET /api/timeline via createApiClient, returns { timeline }, no client fetch | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
| REQ-003 | render bands + entries in DTO order, no client re-sort/re-bucket | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `TimelineView.svelte` | `YearBand.svelte.spec.ts#renders entries in DTO order`, `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>` | Done |
| REQ-004 | ≥1024px centered axis, letters alternating left/right | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` (data-side CSS), `TimelineView.svelte` | `TimelineView.svelte.spec.ts#places consecutive letter cards on alternating sides`, `e2e/zeitstrahl.spec.ts` | Done |
| REQ-005 | <1024px single left axis, no overflow down to 320px | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `LetterCard.svelte` | `e2e/zeitstrahl.spec.ts#no horizontal overflow at 320px with long correspondent names` | Done |
| REQ-006 | single `<ol>` chronological; each band a `<section>` with sticky `<h2>` at top:4rem | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `YearBand.svelte` | `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>`, `YearBand.svelte.spec.ts#sticky h2 at top:4rem` | Done |
| REQ-007 | derived entry → centered family pill with glyph + German derivedType label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte`, `eventCardConfig.ts` | `EventPill.svelte.spec.ts#derived marriage/birth/death`, `eventCardConfig.spec.ts` | Done |
| REQ-008 | curated PERSONAL pill; edit affordance only when eventId != null | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#edit affordance for curated with eventId`, `#no edit affordance when eventId is null`, `#no edit affordance for a derived event` | Done |
| REQ-009 | HISTORICAL → full-width band once in eventDate year; RANGE span pill with Zeitraum aria-label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#RANGE span pill 19141918 with a Zeitraum aria-label` | Done |
| REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year` | Done |
| REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders each letter as a card`, `TimelineView.svelte.spec.ts` | Done |
| REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearLetterStrip.svelte`, `timelineDensity.ts` | `YearLetterStrip.svelte.spec.ts`, `YearBand.svelte.spec.ts#renders a single strip`, `timelineDensity.spec.ts#isDense` | Done |
| REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the precision date exactly`, `#renders no date chip when timelineDateLabel returns null` | Done |
| REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender`, `#empty receiver` | Done |
| REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `GapSpan.svelte` | `TimelineView.svelte.spec.ts#folds an interior run of empty years`, `#single empty interior year`, `GapSpan.svelte.spec.ts` | Done |
| REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders an "Ohne Datum" section`, `#omits the "Ohne Datum" section when empty` | Done |
| REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#shows the empty state and no ol` | Done |
| REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/eventCardConfig.ts`, `EventPill.svelte`, `WorldBand.svelte` | `TimelineView.svelte.spec.ts#redundant non-color cue label`, `EventPill.svelte.spec.ts#wraps the glyph aria-hidden`, `WorldBand.svelte.spec.ts#world glyph` | Done |
| REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done |
| REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#has a touch target of at least 44px` | Done |
| REQ-021 | OCR/import text rendered via `{...}` escaping; no `{@html}` in lib/timeline/ | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/*` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero; `LetterCard.svelte.spec.ts` | Done |
| REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#redirects to /login on 401`, `#404`, `#500`, `#403` | Done |
| REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target` | Done |
| REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels localized per locale) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#timeline layer/derived labels are localized per locale`, Paraglide compile | Done |
| REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done |
| REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done |
| REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done |
| REQ-001 | curator with WRITE_ALL granted access to /zeitstrahl/events/new + /[id]/edit | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#allows a curator with WRITE_ALL`, `[id]/edit/page.server.spec.ts#seeds the form with the event on an ok GET` | Done |
| REQ-002 | unauthenticated (null user) → 403 (null-user guard before groups deref) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#throws 403 for an unauthenticated (null) user`, `[id]/edit/page.server.spec.ts#throws 403 for an unauthenticated (null) user` | Done |
| REQ-003 | authenticated without WRITE_ALL → 403 | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (hasWriteAll) | `new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
| REQ-004 | valid create → POST + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (save), `lib/timeline/eventFormServer.ts#toEventRequest` | `new/page.server.spec.ts#posts a TimelineEventRequest and redirects on success` | Done |
| REQ-005 | valid edit → PUT + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#updates via PUT (with version) and redirects on success` | Done |
| REQ-006 | confirmed delete → DELETE + redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete), `lib/timeline/EventForm.svelte` (getConfirmService) | `[id]/edit/page.server.spec.ts#deletes via DELETE and redirects to the resolved target on success` | Done |
| REQ-007 | non-ok DELETE → surface mapped error, no redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete) | `[id]/edit/page.server.spec.ts#returns fail(status) and does not redirect when DELETE is not ok` | Done |
| REQ-008 | precision = RANGE → end-date field visible | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/EventForm.svelte` | `EventForm.svelte.spec.ts#reveals the end-date field when precision is RANGE`, `WhoWhenSection.svelte.spec.ts#reveals the end-date field when precision is RANGE` | Done |
| REQ-009 | precision ≠ RANGE → end-date hidden, eventDateEnd submitted null | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/eventFormServer.ts#parseEventForm` | `EventForm.svelte.spec.ts#hides the end-date field when precision is YEAR`, `new/page.server.spec.ts#sends eventDateEnd: null when precision is not RANGE` | Done |
| REQ-010 | blank title → localized required error, no nav, picker values preserved | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm`, `EventForm.svelte` | `EventForm.svelte.spec.ts#shows a required-field error when title is blank`, `new/page.server.spec.ts#returns fail(400) with preserved picker arrays on blank title` | Done |
| REQ-011 | blank title + date → both errors via per-field aria-invalid | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm` | `new/page.server.spec.ts#surfaces both title and date errors when both blank` | Done |
| REQ-012 | unknown/derived event id (non-ok GET) → 404, never blank create form | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (load) | `[id]/edit/page.server.spec.ts#throws 404 when the GET is not ok (unknown or derived id)` | Done |
| REQ-013 | 409 Conflict → generic conflict message, no redirect (no merge UI) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#maps a 409 conflict and does not redirect`, `new/page.server.spec.ts#maps the API error and does not redirect on a non-ok save (incl. 409)` | Done |
| REQ-014 | valid ?personId/?documentId prefill pre-selected; unknown id silently ignored | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (load Promise.all), `EventForm.svelte` | `new/page.server.spec.ts#preselects a valid person and ignores an unknown document`, `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done |
| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done |
| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |

View File

@@ -1,42 +0,0 @@
<!--
ADR template. ADRs live in the existing archive: docs/adr/NNN-kebab-title.md.
Verify the next free NNN against `ls docs/adr/` on disk (parallel worktrees make
issue-body numbers stale). An ADR is IMMUTABLE once Status = Accepted — to change a
decision, write a NEW higher-numbered ADR and set this one's Status to Superseded.
This header mirrors the existing archive style (see docs/adr/040-*.md). Delete this comment.
-->
# ADR-NNN — <Short decision title>
**Status:** Proposed <!-- Proposed | Accepted | Deprecated | Superseded by ADR-MMM -->
**Date:** <YYYY-MM-DD>
**Issue:** #<n> <!-- the Gitea issue / feature this decision serves -->
## Context
<The forces at play: what problem demands a decision now, the constraints from the
constitution and existing ADRs, and why the status quo is insufficient. State facts, not
the chosen answer.>
## Decision
<The decision, stated in active voice as something the project now does. Number sub-decisions
(### 1, ### 2, …) if the ADR commits several related choices, matching the existing archive.>
## Alternatives Considered
| Option | Pros | Cons | Reason rejected |
|---|---|---|---|
| <chosen — name it> | <pros> | <cons> | **Chosen** |
| <alternative A> | <pros> | <cons> | <why not> |
| <alternative B> | <pros> | <cons> | <why not> |
## Consequences
<What becomes easier and what becomes harder. Include the obligations this decision places
on future work (migrations forward-only, tests that must exist, guards that must hold), and
any new coupling introduced.>
## References
- <constitution §, related ADRs, issue links, external docs>

View File

@@ -1,97 +0,0 @@
# API Contract Stub
This project is **REST + OpenAPI**. The backend serves the live spec via springdoc at
`http://localhost:8080/v3/api-docs` (dev profile only), and the frontend generates its
TypeScript client from it with `npm run generate:api` (`openapi-typescript`
`frontend/src/lib/generated/api.ts`). There is no GraphQL in this stack.
> **The live spec is generated from the Java controllers — it is the source of truth.** A
> hand-written stub is a *design artifact*: it pins the intended shape during spec review.
> Issue-only: paste the stub inline into the issue's `## API / Contract Stub` section. Keep it
> OpenAPI **3.1**, and keep `@Schema(requiredMode = REQUIRED)` on the Java side as the real
> driver of `required`.
## How to use this stub
1. Fill in the skeleton below with the paths/methods/schemas your feature adds, and paste it
into the issue's `## API / Contract Stub` section.
2. Every mutating path documents the `403`/`401` responses and the `cookieAuth` security
requirement (matching the real `@RequirePermission` gate).
3. If you prefer a standalone, lintable file (e.g. for a large contract), commit it on the
**feature branch** as `<feature>.openapi.yaml` — the `sdd-gate.yml` CI job lints any
committed OpenAPI contract with Spectral (`npx @stoplight/spectral-cli lint`). It never
needs to predate the issue.
4. After the endpoint ships, run `npm run generate:api` and diff the generated types against
this contract; reconcile any drift (the generated spec wins — update the contract).
## OpenAPI 3.1 skeleton
```yaml
openapi: 3.1.0
info:
title: Familienarchiv API — <feature name>
version: 0.0.1-SNAPSHOT
description: Design-time contract for <feature>. Source of truth is the generated /v3/api-docs.
servers:
- url: http://localhost:8080
description: Local backend (dev profile)
- url: https://archiv.raddatz.cloud
description: Production (behind Caddy)
components:
securitySchemes:
cookieAuth: # Spring Session JDBC — opaque session id in the SESSION cookie
type: apiKey
in: cookie
name: SESSION
schemas:
ErrorResponse: # shape produced by GlobalExceptionHandler
type: object
required: [code, message]
properties:
code:
type: string
description: Machine-readable ErrorCode (see ErrorCode.java / errors.ts).
example: FORBIDDEN
message:
type: string
# <YourResponseView>: # always a view, never a lazy-collection entity (ADR-036)
# type: object
# required: [id]
# properties:
# id: { type: string, format: uuid }
security:
- cookieAuth: [] # default: every path requires a session unless overridden to []
paths:
/api/<resource>:
post:
summary: <create …>
operationId: <createResource>
security:
- cookieAuth: [] # plus @RequirePermission(Permission.X) on the controller
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/<CreateDTO>' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/<YourResponseView>' }
'400': { description: Validation failed, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
'403': { description: Missing permission, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
```
## Validating the contract in CI
The `sdd-gate.yml` `contract-validate` job lints any committed OpenAPI file changed in the PR:
```bash
npx @stoplight/spectral-cli lint <your-contract>.yaml
```
The ruleset is `.spectral.yaml` at the repo root (extends `spectral:oas`; documentation-only
warnings relaxed for design-time stubs). Spectral auto-discovers it. It catches malformed
specs, undefined `$ref`s, and duplicate `operationId`s; tune `.spectral.yaml` to adjust.

View File

@@ -1,89 +0,0 @@
<!--
Feature Spec template — paste this into the Gitea issue body (issue-only: this IS the spec;
there is no committed spec.md). The .gitea/ISSUE_TEMPLATE/feature.md mirror gives the same
structure with the right labels. Replace every <placeholder>. Delete this comment before submitting.
EARS = Easy Approach to Requirements Syntax. Every requirement uses one of the five patterns
shown in ## Requirements and carries a unique REQ-NNN id (three-digit, scoped to THIS feature).
Use plain code-path references (not relative markdown links) — links don't resolve inside a Gitea issue.
-->
# <Feature title — match the Gitea issue: "As a <role> I want <capability> so <reason>">
## Context & Why
<Business motivation in 24 sentences: who needs this and why now.>
Constitution principles this feature depends on (see `.specify/constitution.md`):
- §<n> <principle name> — <why it applies>
Related: <links to prior issues / ADRs>.
## User Journey
<Plain-prose steps the user takes to get value, from the user's perspective — per COLLABORATING.md. Anything not in this journey is out of scope.>
## Requirements
> One requirement per line, each with a `REQ-NNN` id and one EARS pattern. Include the
> patterns the feature actually needs — do not force all five, but a mutating feature almost
> always needs at least one Event-driven and one Unwanted-behavior requirement.
- **REQ-001** (Ubiquitous) — The `<system component>` shall `<always-true behavior>`.
- **REQ-002** (Event-driven) — When `<trigger / endpoint receives X>`, the `<system component>` shall `<response>`.
- **REQ-003** (State-driven) — While `<system is in state X>`, the `<system component>` shall `<behavior>`.
- **REQ-004** (Optional-feature) — Where `<the caller has Permission.X / a feature flag is set>`, the `<system component>` shall `<behavior>`.
- **REQ-005** (Unwanted-behavior) — If `<undesired condition, e.g. caller is unauthenticated / input invalid>`, then the `<system component>` shall `<safe response, e.g. return 401 / ErrorCode.X>`.
## Acceptance Criteria
> One measurable criterion per REQ-NNN. Numbers, limits, status codes — never adjectives.
- **REQ-001** — <measurable, e.g. "the response always includes a non-null `id` (UUID)">.
- **REQ-002** — <measurable, e.g. "POST returns 201 and the persisted row within the same request">.
- **REQ-003** — <measurable>.
- **REQ-004** — <measurable, e.g. "a caller without Permission.X receives 403 with ErrorCode.FORBIDDEN">.
- **REQ-005** — <measurable, e.g. "an unauthenticated request receives 401 and nothing is persisted">.
## Out of Scope
- <Explicit boundary statement — the nearest tempting scope creep, named and excluded.>
- <…>
## API / Contract Stub
<Inline OpenAPI stub. Name the new/changed paths, methods, request/response shapes, status codes, and `@RequirePermission`. Use the `.specify/templates/api-contract-stub.md` skeleton as a writing aid.>
## Data Model Changes
<Entity/schema delta: new tables/columns, constraints, the next free Flyway `V<n>`, and the rollback note. Write "none" if not applicable.>
## Security Considerations
<STRIDE categories touched (Spoofing/Tampering/Repudiation/Information disclosure/DoS/Elevation). For AI-agent/tool features, also ASTRIDE. Include an inline STRIDE table (use `.specify/templates/threat-model.md`) if the feature has a non-trivial attack surface.>
## Open Questions
> Each item is a BLOCKER until resolved. Empty this list before implementation starts.
- [ ] <question> — owner: <name>
- [ ] <question> — owner: <name>
## Traceability
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|---|---|---|---|
| REQ-001 | <T-1> | <test name> | Planned |
| REQ-002 | <T-2> | <test name> | Planned |
<After approval, add one committed row per REQ-NNN to `.specify/rtm.md` with this issue's number. Fill Task/Test IDs as work progresses.>
## Persona Review Results
| Persona | Status | Key Findings | Resolved |
|---|---|---|---|
| Requirements Engineer | PENDING | | |
| Developer | PENDING | | |
| Security | PENDING | | |
| DevOps | PENDING | | |
| UI/UX | PENDING | | |
| Architect | PENDING | | |

View File

@@ -1,53 +0,0 @@
<!--
Threat model template — STRIDE + ASTRIDE. WRITING AID: fill this in and paste the result into
the issue's "## Security Considerations" section (issue-only — the threat model lives in the
issue body, not a committed file). Required when a feature adds a new trust boundary, handles
uploads, exposes a new mutating endpoint, or invokes an AI agent/tool. The Security persona
gates it during /review-issue. Delete this comment.
-->
# Threat Model — <Feature name>
**Feature spec:** Gitea issue #<n>
**Date:** <YYYY-MM-DD>
**Author:** <name>
## Data Flow Diagram (text)
**Actors**
- <e.g. Anonymous visitor, Authenticated reader, Authenticated transcriber, Admin, OCR sidecar>
**Trust boundaries**
- TB-1: Browser ⇄ Caddy (public internet ⇄ DMZ)
- TB-2: Caddy ⇄ Backend (`:8080`) (DMZ ⇄ app)
- TB-3: Backend ⇄ PostgreSQL / MinIO / sidecars (app ⇄ data plane)
- <add feature-specific boundaries>
**Data flows** (source → [boundary] → sink : data)
- F-1: Browser → [TB-1,TB-2] → Backend : <request payload>
- F-2: Backend → [TB-3] → MinIO : <stored object>
- <…>
## STRIDE
| Threat Category | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|---|---|---|---|---|---|
| **S**poofing | <asset> | <e.g. unauthenticated caller forges a request> | <session auth + @RequirePermission> | Low × High | <Open/Mitigated/Accepted> |
| **T**ampering | <asset> | <e.g. mass-assignment of createdBy> | <server-set audit fields, no body binding> | Med × High | |
| **R**epudiation | <asset> | <e.g. no record of who changed what> | <NOT NULL createdBy/updatedBy audit trail> | Low × Med | |
| **I**nformation disclosure | <asset> | <e.g. entity leaks email/hash; raw 500 leaks Hibernate internals> | <view not entity; DomainException.conflict> | Med × High | |
| **D**enial of service | <asset> | <e.g. oversized upload / unbounded list> | <size limit, batch cap, pagination> | Med × Med | |
| **E**levation of privilege | <asset> | <e.g. reader reaches a write endpoint / IDOR> | <least-privilege Permission, ownership check> | Low × High | |
## ASTRIDE (only if the feature invokes an AI agent / tool — OCR, NLP, LLM)
| Threat | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|---|---|---|---|---|---|
| Prompt Injection | <input to the model> | <untrusted document text steers the model> | <treat model output as untrusted; no auto-exec> | | |
| Context Poisoning | <retrieved/shared context> | <attacker plants data that biases later runs> | <scope/provenance of context; validation> | | |
| Unsafe Tool Invocation | <tool the agent can call> | <model triggers a privileged action> | <allow-list tools; human-in-loop on mutations> | | |
| Reasoning Subversion | <decision the model makes> | <crafted input flips a classification/decision> | <confidence threshold; deterministic guardrail> | | |
## Residual Risk
<Threats marked Accepted, who accepted them, and why the residual risk is tolerable.>

View File

@@ -1,15 +0,0 @@
# Spectral ruleset for OpenAPI contract linting (SDD api-contract files).
# Spectral v6 ships no implicit ruleset — this enables the built-in OpenAPI rules.
# Used by .gitea/workflows/sdd-gate.yml (contract-validate) and locally:
# npx @stoplight/spectral-cli lint <contract>.yaml
extends: ["spectral:oas"]
rules:
# Design-time SDD stubs are not full published API docs — relax the documentation-completeness
# warnings that would otherwise fire on a focused contract. The structural/correctness rules
# (oas3-schema, valid $refs, duplicate operationId, etc.) stay on.
info-contact: off
info-description: off
operation-description: off
operation-tag-defined: off
oas3-unused-component: off

View File

@@ -16,10 +16,6 @@ See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking wo
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
## Spec-Driven Development
This project uses Spec-Driven Development. **Before implementing a feature, read [`.specify/AGENTS.md`](./.specify/AGENTS.md)** (the short, machine-readable agent rules) and obey the [`.specify/constitution.md`](./.specify/constitution.md) it references. A feature's contract is its **Gitea issue body** (EARS `REQ-NNN` requirements) — there is no committed `spec.md`; the RTM ([`.specify/rtm.md`](./.specify/rtm.md)) traces each `REQ-ID → issue # → test`. Full workflow: [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md); template/reference: [`.specify/features/_example/`](./.specify/features/_example/). The LLM reminders below restate constitution rules — the constitution and AGENTS.md are authoritative if they ever diverge.
---
## Stack
@@ -99,7 +95,6 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline
└── user/ User domain — AppUser, UserGroup, UserService
```
@@ -120,8 +115,6 @@ backend/src/main/java/org/raddatz/familienarchiv/
| `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` |
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for all timeline entries assembled by `TimelineService`; 13 fields: `kind` (`EVENT`\|`LETTER`), `precision` (raw `DatePrecision` enum), `derived` (boolean), `senderName` (non-null `String`, `""` = unknown), `receiverName` (non-null `String`, `""` = unknown), `eventDate`, `eventDateEnd`, `title`, `type` (`EventType`, null for LETTER), `eventId` (null for derived entries and letters), `documentId` (set for letters), `linkedPersonIds: List<UUID>`, `derivedType` (`DerivedEventType`, null for curated/letters); edit-affordance contract: `derived == true \|\| eventId == null` → no edit link |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -170,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); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
**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`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
### Security / Permissions
@@ -207,8 +200,6 @@ frontend/src/routes/
├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode)
│ └── events/ Curator event editor (WRITE_ALL-gated) — new (create) + [id]/edit (edit + delete); reuses lib/timeline/EventForm
├── themen/ Topics directory — browsable tag index
├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management
@@ -280,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); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
**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`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
---

View File

@@ -8,14 +8,6 @@ Evaluate all suggestions on their technical merits. No sycophancy — if somethi
## Core Workflow: Research → Plan → Implement → Validate
> **Spec-Driven Development.** Feature work is front-ended by an SDD spec: EARS-formatted
> `REQ-NNN` requirements, persona spec-review checklists, and the project constitution. The
> sequence below is unchanged — SDD formalises its *inputs* (the issue body becomes a
> structured spec; the User Journey + E2E Scenarios below feed it). See
> [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md) and
> [`.specify/`](./.specify/) ([constitution](./.specify/constitution.md),
> [AGENTS.md](./.specify/AGENTS.md)).
Every non-trivial feature or bug fix follows this sequence:
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.

View File

@@ -1,7 +1,6 @@
# Contributing to Familienarchiv
For the full collaboration rules (issue workflow, PR process, Red/Green TDD, commit conventions) see [COLLABORATING.md](./COLLABORATING.md).
For the Spec-Driven Development workflow (EARS specs, persona review, the constitution, and `.specify/`) see [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md).
For coding style see [CODESTYLE.md](./CODESTYLE.md).
For the system architecture see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (introduced in DOC-2; until that PR merges, see [docs/architecture/c4-diagrams.md](./docs/architecture/c4-diagrams.md)).
For domain terminology see [docs/GLOSSARY.md](./docs/GLOSSARY.md).

View File

@@ -1,235 +0,0 @@
# Spec-Driven Development (SDD)
How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform,
machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR →
multi-persona review → red/green TDD). It does not replace any of that — see
[ADR-042](./docs/adr/042-sdd-adoption.md) for the why.
- **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and
[`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation).
- **The templates** live in [`.specify/templates/`](./.specify/templates/).
- **The worked example** is [`.specify/features/_example/`](./.specify/features/_example/) — read it first.
---
## 0. The whole workflow at a glance
```mermaid
flowchart TD
idea([Feature idea]):::start --> draft
subgraph author["✍️ Author"]
draft[/"/draft-spec<br/>(Requirements Engineer)"/]:::skill --> issue[("Gitea issue = the SPEC<br/>EARS REQ-NNN + acceptance criteria")]:::spec
end
issue --> ri[/"/review-issue"/]:::skill
ri --> g1{"GATE 1 · spec review<br/>6 personas APPROVE?<br/>Open Questions empty?"}:::gate
g1 -- "FAIL / question" --> amend["Amend the issue body"]:::work --> ri
g1 -- "APPROVE" --> rtm["Seed RTM rows<br/>REQ-ID → issue #"]:::work
rtm --> wt["Create git worktree<br/>(pull main first)"]:::work --> impl[/"/implement"/]:::skill
subgraph build["🔁 Build · TDD per REQ-NNN"]
impl --> red["Red: failing test"]:::work --> green["Green: minimal code"]:::work --> sync["Refactor + sync<br/>generate:api · flip RTM → Done"]:::work --> commit["Commit · Refs #n"]:::work
commit -- "next REQ" --> red
end
build --> pr[["Open PR · Closes #n"]]:::work --> g2{"GATE 2 · CI green?<br/>ci.yml + sdd-gate.yml"}:::gate
g2 -- "red" --> fixci["Fix on branch"]:::work --> g2
g2 -- "green" --> rp[/"/review-pr"/]:::skill
rp --> g3{"GATE 3 · PR review<br/>all personas APPROVE?<br/>every REQ implemented + tested?<br/>no Do-Not-Touch violation?"}:::gate
g3 -- "changes requested" --> fixpr["Fix on branch"]:::work --> rp
g3 -- "APPROVE" --> merge([Merge → main<br/>closed issue = archived spec]):::start
rules["📐 constitution.md + AGENTS.md<br/>(bind every step)"]:::rules -.-> draft
rules -.-> impl
rules -.-> rp
classDef start fill:#1d3b53,color:#fff,stroke:#1d3b53;
classDef skill fill:#e8f5f0,stroke:#3aa884,color:#13352b;
classDef gate fill:#fff3cd,stroke:#d39e00,color:#5a4500;
classDef spec fill:#eef2ff,stroke:#5b6ee1,color:#1e2a5a;
classDef work fill:#f6f6f6,stroke:#bbb,color:#222;
classDef rules fill:#fdecea,stroke:#d9534f,color:#611a15;
```
> `/deliver-issue` runs **GATE 1 → discuss → build → GATE 3 (loop)** end-to-end in one go.
### Prerequisites (one-time setup)
Before the workflow runs cleanly, confirm these exist (most ship with this repo):
- [ ] **Gitea labels** `spec-required` and `needs-review` exist (the feature template + `/draft-spec` attach them; the `labels` create-param is ignored, so they must pre-exist).
- [ ] **Gitea MCP** server configured (`gitea`) — the skills read/write issues and PRs through it.
- [ ] **`.spectral.yaml`** at the repo root (extends `spectral:oas`) — the CI contract check needs it.
- [ ] **Personas present**: identities in [`.claude/personas/`](./.claude/personas/) + checklists in [`.specify/personas/`](./.specify/personas/).
- [ ] **`.specify/constitution.md` + `AGENTS.md`** committed on `main` (so every branch inherits them).
- [ ] **Worktrees + hooks**: new feature work goes in a `git worktree` (plus-free name); run `npm install` in `frontend/` once per worktree so the pre-commit lint hook works.
### The three gates
| Gate | When | Mechanism | Blocks on |
|---|---|---|---|
| **1 · Spec review** | after `/draft-spec`, before any code | `/review-issue` (6 persona checklists) | any persona `CHANGES REQUESTED`, or an unresolved `## Open Question` |
| **2 · CI** | on every PR | `ci.yml` (tests · lint · semgrep) + `sdd-gate.yml` (rtm-check · contract-validate · constitution-diff) | `ci.yml` failure (hard); `sdd-gate` jobs are non-blocking during adoption — see the workflow TODO |
| **3 · PR review** | before merge | `/review-pr` (7 personas + traceability) | any persona `Changes requested`, an unimplemented/untested `REQ-NNN`, or a constitution Do-Not-Touch violation |
---
## 1. The workflow in 8 steps
| # | Step | Who | Artifacts created / touched |
|---|---|---|---|
| 1 | **Idea → Gitea issue** using the Feature template | author | Gitea issue (labels `spec-required`, `needs-review`) from `.gitea/ISSUE_TEMPLATE/feature.md` |
| 2 | **Write the spec _in the issue body_** — Context, User Journey, EARS `REQ-NNN` requirements, measurable acceptance criteria, Out of Scope | author | the Gitea issue body **is** the spec (single source of truth — no committed `spec.md`) |
| 3 | **Capture durable design decisions** as needed | author | a `docs/adr/` ADR for any project-wide/irreversible decision; an OpenAPI contract and a STRIDE threat model inline in the issue (use the `.specify/templates/` as the writing aid) |
| 4 | **Persona spec review** — the six checklists gate the spec | RE, Developer, Security, DevOps, UI/UX, Architect | `/review-issue` posts each persona's checklist verdict as a Gitea comment; findings folded into the issue body |
| 5 | **Resolve Open Questions & blocking FAILs** — spec does not proceed while any remain | author | issue body updated; `Open Questions` emptied |
| 6 | **Seed the RTM** — one row per `REQ-NNN`, pointing at the issue | author | rows added to [`.specify/rtm.md`](./.specify/rtm.md) (`Issue: #n`, `Status: Planned`) — committed with the feature branch |
| 7 | **Implement** in a worktree, TDD per task (failing test → green → refactor → commit); agent reads `AGENTS.md` + the **issue body** (the spec) | implementer (often an AI agent) | code + tests; `npm run generate:api` after backend changes; RTM `Status``Done` |
| 8 | **PR → multi-persona PR review → merge** | reviewers | PR (`Closes #n`); the closed issue is the archived spec, the RTM rows record what shipped |
The personas at step 4 review the **spec (the issue)**; the same personas at step 8 (via the
existing `review-pr` / `deliver-issue` skills) review the **code**. Step 4 catches at spec time
what used to surface only at step 8.
**Skills that drive this:** `/draft-spec` (requirements engineer authors steps 12 → creates
the issue) → `/review-issue` (step 4 gate) → `/implement` (steps 67) → `/review-pr` (step 8).
`/deliver-issue` runs review → discuss → implement → review-loop end-to-end.
> **Why issue-only?** The Gitea issue body is the single source of truth for a spec — there is
> no committed per-feature `spec.md` to drift out of sync with it. The only SDD artifact that
> lives in git per feature is the RTM row (`REQ-ID → issue # → test`). The worked example under
> [`.specify/features/_example/`](./.specify/features/_example/) is a **template/reference**, not
> a live feature — it shows the full artifact set in one place; real features keep the spec in
> the issue.
## 2. How a Gitea issue becomes a spec
**Before (free-form issue):**
> **Title:** Add profile pictures
> Users should be able to upload a picture for their profile. Make sure it's not too big and
> only admins can remove other people's. Show initials if there's no picture.
Ambiguous: how big? which formats? what status code on rejection? what about unauthenticated
callers? No identifiers to trace, no measurable criteria.
**After (SDD-structured issue — excerpt):**
> **Title:** As a user I want to upload a profile picture so other family members recognise me
>
> **## Requirements**
> - **REQ-002** (Event-driven) — When an authenticated user sends `POST /api/users/me/avatar`
> with a valid image, the user service shall store it and return a profile view with a
> non-null `avatarUrl`.
> - **REQ-008** (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service
> shall return `400 ErrorCode.AVATAR_TOO_LARGE` and store nothing.
> - **REQ-009** (Unwanted-behavior) — If a caller without `Permission.ADMIN_USER` targets
> another user's avatar, then the system shall return `403 ErrorCode.FORBIDDEN`.
>
> **## Acceptance Criteria**
> - **REQ-008** — a 2.1 MB PNG returns `400 AVATAR_TOO_LARGE`; bucket object count unchanged.
Every behavior is now a uniquely-identified, testable, EARS-formed requirement with a
measurable acceptance criterion. See the full version in
[`.specify/features/_example/spec.md`](./.specify/features/_example/spec.md).
## 3. How to run a persona review
Each persona reads the spec, walks its checklist in `.specify/personas/<persona>.md`, and
posts a Gitea comment with **PASS / FAIL / QUESTION** per
item and a verdict. A `FAIL` from Security or Architect is a hard block. Concrete example:
> ### Security — Spec Review
>
> | # | Item | Status | Note |
> |---|---|---|---|
> | 1 | All mutating endpoints have authn + authz `If` clauses | PASS | REQ-006 (401), REQ-009 (403) |
> | 3 | Audit fields server-set, forbidden in body | **FAIL** | `avatarObjectKey` is bound from the request body → mass-assignment (CWE-639). Make it server-set in `UserService`. |
> | 6 | Upload type allow-list + size | PASS | REQ-007 / REQ-008 |
> | 9 | threat-model.md present & STRIDE-complete | **QUESTION** | Is the avatar URL public or proxied? If public S3, that's information disclosure. |
>
> **Verdict: CHANGES REQUESTED** — blocking FAIL: #3. Resolve #9 in the threat model.
The author folds the fix into the spec (here: server-set key + authenticated proxy URL),
empties the finding, and the persona re-reviews until `APPROVE`. This mirrors the existing
`review-issue` skill — the persona checklists just make the spec pass/fail explicit.
## 4. How the AI agent uses the spec
Once the spec is `APPROVE`d and tasks are seeded, the implementer points the agent at the
artifacts. Example prompt:
> Implement Gitea issue #142 (profile picture upload). Read `.specify/AGENTS.md` and obey the
> constitution it references. The contract is the issue body — its EARS requirements
> REQ-001…REQ-009 and acceptance criteria. Build a red/green task list from them, write the
> failing test for each REQ first, confirm it fails, then make it pass. After backend model
> changes run `npm run generate:api`. Do not mark a REQ done until its test is green; flip its
> row in `.specify/rtm.md` to Done as you go.
The agent now has: the rules (`AGENTS.md` → constitution) and the exact requirements with ids
from the issue — so its output is bounded and verifiable. (The `/implement` skill fetches the
issue body for you via the Gitea API.)
## 5. Maintenance rules
- **Constitution** ([`.specify/constitution.md`](./.specify/constitution.md)) — change it only
when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule
removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact
review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump
in ADR-042's revision log (or a superseding ADR for MAJOR).
- **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never
duplicate or contradict it.
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free
`NNN`, verify on disk). Immutable once `Accepted`; supersede, don't edit.
- **Feature specs** — the spec is the Gitea issue body; there is no committed `spec.md`.
"Archiving" is just closing the issue (`Closes #n` on merge). The closed issue + the RTM
rows are the record of what shipped.
- **RTM** ([`.specify/rtm.md`](./.specify/rtm.md)) — append one row per `REQ-NNN` when a spec
is approved, each pointing at its issue (`#n`); flip `Status` as tests go green; never delete
a shipped requirement's row.
- **Personas** — update `.specify/personas/*.md` checklists when a recurring blind spot
appears; keep them aligned with the richer `.claude/personas/`.
## 6. Quick-start cheatsheet
**EARS patterns** (every requirement is one of these + a `REQ-NNN` id):
| Pattern | Shape |
|---|---|
| Ubiquitous | `The <system> shall <behavior>.` |
| Event-driven | `When <trigger>, the <system> shall <behavior>.` |
| State-driven | `While <state>, the <system> shall <behavior>.` |
| Optional-feature | `Where <feature/permission present>, the <system> shall <behavior>.` |
| Unwanted-behavior | `If <undesired condition>, then the <system> shall <response>.` |
**File locations:**
| What | Where |
|---|---|
| Non-negotiable rules | `.specify/constitution.md` |
| Agent rules (read every time) | `.specify/AGENTS.md` |
| Templates (writing aids) | `.specify/templates/{feature-spec,adr,threat-model,api-contract-stub}.md` |
| Persona checklists | `.specify/personas/*.md` |
| In-flight feature spec | the **Gitea issue body** (not a committed file) |
| Worked example (template/reference) | `.specify/features/_example/` |
| Traceability matrix | `.specify/rtm.md` (`REQ-ID → issue # → test`) |
| ADR archive | `docs/adr/NNN-*.md` |
| Issue templates | `.gitea/ISSUE_TEMPLATE/{feature,bug}.md` |
| CI gate | `.gitea/workflows/sdd-gate.yml` |
**Before you mark a feature done:** every `REQ-NNN` has a green test, the RTM Status is
`Done`, all six personas APPROVE, `npm run lint` and the targeted tests pass, and
`npm run generate:api` has been run if the backend model changed.
**Commands:**
```bash
# validate an OpenAPI contract locally (if you drafted one — same as CI)
npx @stoplight/spectral-cli lint <your-contract>.yaml
# regenerate the TS client after a backend model/endpoint change
cd frontend && npm run generate:api # backend must run with --spring.profiles.active=dev
```

View File

@@ -42,7 +42,6 @@ src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ # PersonRelationship sub-domain
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ # Tag domain — Tag, TagService, TagController
├── timeline/ # Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
└── user/ # User domain — AppUser, UserGroup, UserService
```
@@ -68,7 +67,6 @@ For per-domain ownership and public surface, see each domain's `README.md`.
| `Comment` | `document_comments` | Threaded comments with mentions |
| `Notification` | `notifications` | User notification feed |
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
| `TimelineEvent` | `timeline_events` | Curated Zeitstrahl event; ManyToMany persons + documents (join FKs ON DELETE CASCADE); `@Version` + NOT NULL createdBy/updatedBy |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`

View File

@@ -56,11 +56,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
boolean existsByOriginalFilename(String originalFilename);
// Bulk-fetch for global timeline path — single query with sender+receivers eager-loaded.
@EntityGraph("Document.list")
@Query("SELECT d FROM Document d")
List<Document> findAllForTimeline();
// lazy @BatchSize(50) fallback active; see ADR-022
@EntityGraph("Document.full")
List<Document> findBySenderId(UUID senderId);

View File

@@ -1051,10 +1051,6 @@ public class DocumentService {
return documentRepository.findDocumentsWithoutVersions();
}
public List<Document> getAllForTimeline() {
return documentRepository.findAllForTimeline();
}
public List<Document> getDocumentsBySender(UUID senderId) {
return documentRepository.findBySenderId(senderId);
}

View File

@@ -15,10 +15,6 @@ public enum ErrorCode {
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
/** A person's birth date is after their death date. 400 */
BIRTH_AFTER_DEATH,
/** A life date and its precision are incoherent: date present with UNKNOWN precision, or precision set without a date. 400 */
INVALID_DATE_PRECISION,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,
@@ -155,14 +151,6 @@ public enum ErrorCode {
/** The merge target is a descendant of the source tag. 400 */
TAG_MERGE_INVALID_TARGET,
// --- Timeline (Zeitstrahl) ---
/** A timeline event with the given ID does not exist. 404 */
TIMELINE_EVENT_NOT_FOUND,
/** Optimistic-locking conflict — the timeline event was modified by another curator. 409 */
TIMELINE_EVENT_CONFLICT,
/** A timeline event title exceeds the maximum length (255 characters — the DB column bound). 400 */
TIMELINE_TITLE_TOO_LONG,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,
@@ -170,8 +158,6 @@ public enum ErrorCode {
BATCH_TOO_LARGE,
/** Bulk edit request exceeds the per-request document ID cap. 400 */
BULK_EDIT_TOO_MANY_IDS,
/** A concurrent modification was detected (generic optimistic-lock backstop). 409 */
CONFLICT,
/** An unexpected server-side error occurred. 500 */
INTERNAL_ERROR,
}

View File

@@ -104,30 +104,6 @@ public class GlobalExceptionHandler {
return "unknown";
}
/**
* Generic backstop for optimistic-locking conflicts that escape a service-level catch. A
* conflict is a 409, not a system fault — so, like {@link #handleDataIntegrityViolation}, it
* must NOT fire Sentry and must NOT leak Hibernate internals (CWE-209): the response carries
* only the generic {@link ErrorCode#CONFLICT} code and a generic message — no entity id, no
* version, no persistent-class name.
*
* <p>Deliberately code-GENERIC: do NOT {@code switch} on {@code getPersistentClassName()} to map
* back to a per-entity code. Unlike {@link #handleDataIntegrityViolation}, which branches on
* stable schema constraint NAMES, persistent-class names are not a contract. The precise,
* code-carrying path is the service catch (e.g. {@code TIMELINE_EVENT_CONFLICT}); this is only
* the net that keeps any current or future write path from regressing to a 500.
*/
@ExceptionHandler(org.springframework.orm.ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ErrorResponse> handleOptimisticLock(
org.springframework.orm.ObjectOptimisticLockingFailureException ex) {
// Log the persistent-class name ONLY (schema metadata, safe for Loki). Never `ex` /
// ex.getMessage(): those embed the entity id + version (CWE-209). No Sentry: it's a 409.
log.warn("Rejected a write that lost an optimistic-lock race on: {}", ex.getPersistentClassName());
return ResponseEntity.status(409)
.body(new ErrorResponse(ErrorCode.CONFLICT,
"The resource was modified concurrently. Please reload and try again."));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
Sentry.captureException(ex);

View File

@@ -50,9 +50,6 @@ public class GeschichteService {
private static final int DEFAULT_LIMIT = 50;
private static final int MAX_LIMIT = 200;
/** Sentinel used when {@code personIds} is empty to avoid invalid empty IN() SQL. */
private static final UUID NIL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
// turns what would be a DB-level 500 into a friendly 400.
static final int MAX_TITLE_LENGTH = 255;
@@ -68,7 +65,6 @@ public class GeschichteService {
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
}
// readOnly = true: lazy collections resolve within the same tx when called from getView()
@Transactional(readOnly = true)
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
@@ -117,22 +113,17 @@ public class GeschichteService {
*
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
* LazyInitializationException on the non-transactional list path.
*
* <p>Security: {@code null} status always resolves to PUBLISHED — even for blog writers.
* Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts.
* This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts.
*/
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT;
GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED;
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
UUID authorId = isDraftRequest ? currentUser().getId() : null;
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
// 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(NIL_UUID)
? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
: personIds;
long personCount = (personIds == null) ? 0 : personIds.size();

View File

@@ -38,11 +38,8 @@ public class JourneyItem {
@JsonIgnore
private Document document;
/**
* Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output.
*
* <p>CWE-79 tripwire: stored verbatim; only Svelte {note} interpolation is auto-safe.</p>
*/
// 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;

View File

@@ -250,11 +250,9 @@ public class JourneyItemService {
}
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
Throwable cause = e.getCause();
if (cause instanceof java.sql.SQLException sql) {
return "23505".equals(sql.getSQLState());
}
return false;
Throwable cause = e.getMostSpecificCause();
String message = cause != null ? cause.getMessage() : e.getMessage();
return message != null && message.contains("uq_journey_items_geschichte_document");
}
private static String normalizeNote(String raw) {

View File

@@ -12,6 +12,5 @@ public record JourneyItemView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
DocumentSummary document,
/** Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output. */
String note
) {}

View File

@@ -6,9 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -51,25 +49,8 @@ public class Person {
@Column(columnDefinition = "TEXT")
private String notes;
// Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
// the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning
// "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN).
// DatePrecision is imported cross-domain from document/ by design (ADR-039).
private LocalDate birthDate;
@Enumerated(EnumType.STRING)
@Column(name = "birth_date_precision", nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;
private LocalDate deathDate;
@Enumerated(EnumType.STRING)
@Column(name = "death_date_precision", nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;
private Integer birthYear;
private Integer deathYear;
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
// Nullable for persons outside the curated family graph. Drives the

View File

@@ -66,10 +66,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -82,10 +79,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -95,7 +89,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)
@@ -106,10 +100,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -148,10 +139,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -242,7 +230,4 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
)
""", nativeQuery = true)
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
// Boxed Integer — matches the nullable person.generation column (primitive int would reject null rows).
List<Person> findByGeneration(Integer generation);
}

View File

@@ -1,6 +1,5 @@
package org.raddatz.familienarchiv.person;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
@@ -17,7 +16,6 @@ import org.springframework.lang.Nullable;
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
@@ -210,10 +208,6 @@ public class PersonService {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
public List<Person> getPersonsByGeneration(Integer generation) {
return personRepository.findByGeneration(generation);
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
Person person = getById(personId);
@@ -305,20 +299,13 @@ public class PersonService {
}
private Person fromCanonical(PersonUpsertCommand cmd) {
DatePrecisionPair none = new DatePrecisionPair(null, DatePrecision.UNKNOWN);
LifeDates dates = degradeIfConflicting(
yearPair(cmd.birthYear()), yearPair(cmd.deathYear()), none, none, cmd.sourceRef());
DatePrecisionPair birth = dates.birth();
DatePrecisionPair death = dates.death();
Person person = personRepository.save(Person.builder()
.sourceRef(cmd.sourceRef())
.firstName(blankToNull(cmd.firstName()))
.lastName(cmd.lastName())
.notes(blankToNull(cmd.notes()))
.birthDate(birth.date())
.birthDatePrecision(birth.precision())
.deathDate(death.date())
.deathDatePrecision(death.precision())
.birthYear(cmd.birthYear())
.deathYear(cmd.deathYear())
.generation(cmd.generation())
.familyMember(cmd.familyMember())
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
@@ -341,16 +328,8 @@ public class PersonService {
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
LifeDates dates = degradeIfConflicting(
preferHumanDate(existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()),
preferHumanDate(existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()),
new DatePrecisionPair(existing.getBirthDate(), existing.getBirthDatePrecision()),
new DatePrecisionPair(existing.getDeathDate(), existing.getDeathDatePrecision()),
cmd.sourceRef());
existing.setBirthDate(dates.birth().date());
existing.setBirthDatePrecision(dates.birth().precision());
existing.setDeathDate(dates.death().date());
existing.setDeathDatePrecision(dates.death().precision());
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
existing.setPersonType(cmd.personType());
@@ -377,48 +356,6 @@ public class PersonService {
return existing != null ? existing : canonical;
}
// Date + precision travel as one value so they can never go out of sync (ADR-039).
record DatePrecisionPair(LocalDate date, DatePrecision precision) {}
record LifeDates(DatePrecisionPair birth, DatePrecisionPair death) {}
// The canonical path skips validateLifeDates (the form-only guard), so a conflicting
// resolved pair would hit chk_person_birth_before_death at flush time and abort the
// whole import batch with a raw 500. Degrade instead (REQ-IMP-001: never abort the
// batch): keep the person's stored life dates — empty for a new person — and drop the
// conflicting canonical refresh. A hand-entered side is preserved by construction,
// since preferHumanDate returned it verbatim and it equals the stored value; two
// stored values can never conflict with each other (they already satisfied the CHECK).
static LifeDates degradeIfConflicting(DatePrecisionPair birth, DatePrecisionPair death,
DatePrecisionPair existingBirth, DatePrecisionPair existingDeath,
String sourceRef) {
if (birth.date() == null || death.date() == null || !birth.date().isAfter(death.date())) {
return new LifeDates(birth, death);
}
log.warn("Conflicting canonical life dates for {}: birth {} is after death {} — keeping stored values",
sourceRef, birth.date(), death.date());
return new LifeDates(existingBirth, existingDeath);
}
// preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than
// the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a
// YEAR-precision or absent date is refreshed from the canonical year.
static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision,
Integer canonicalYear) {
boolean handEntered = existingDate != null && existingPrecision != null
&& existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN;
if (handEntered) {
return new DatePrecisionPair(existingDate, existingPrecision);
}
return yearPair(canonicalYear);
}
private static DatePrecisionPair yearPair(Integer year) {
return year != null
? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR)
: new DatePrecisionPair(null, DatePrecision.UNKNOWN);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
@@ -438,8 +375,7 @@ public class PersonService {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
}
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
dto.getDeathDate(), dto.getDeathDatePrecision());
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
.personType(dto.getPersonType())
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
@@ -447,49 +383,31 @@ public class PersonService {
.lastName(dto.getLastName())
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthDate(dto.getBirthDate())
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate())
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
.birthYear(dto.getBirthYear())
.deathYear(dto.getDeathYear())
.generation(dto.getGeneration())
.build();
return personRepository.save(person);
}
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
"Birth date " + birthDate + " is after death date " + deathDate);
private void validateYears(Integer birthYear, Integer deathYear) {
if (birthYear != null && birthYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
}
}
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date is set but its precision is missing or UNKNOWN");
if (deathYear != null && deathYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
}
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date precision " + precision + " is set without a date");
if (birthYear != null && deathYear != null && birthYear > deathYear) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
}
}
private static DatePrecision normalizePrecision(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
}
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
dto.getDeathDate(), dto.getDeathDatePrecision());
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setPersonType(dto.getPersonType());
@@ -498,10 +416,8 @@ public class PersonService {
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthDate(dto.getBirthDate());
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate());
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
// Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration());

View File

@@ -1,8 +1,5 @@
package org.raddatz.familienarchiv.person;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.UUID;
/**
@@ -19,13 +16,6 @@ public interface PersonSummaryDTO {
String getAlias();
Integer getBirthYear();
Integer getDeathYear();
// Full date + precision alongside the derived years: list consumers that render
// precise life dates (mention dropdown) read these; year-only consumers keep
// the cheaper getBirthYear/getDeathYear.
LocalDate getBirthDate();
DatePrecision getBirthDatePrecision();
LocalDate getDeathDate();
DatePrecision getDeathDatePrecision();
String getNotes();
boolean isFamilyMember();
boolean isProvisional();

View File

@@ -5,11 +5,8 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.PersonType;
import java.time.LocalDate;
@Data
public class PersonUpdateDTO {
@NotNull
@@ -24,10 +21,8 @@ public class PersonUpdateDTO {
private String alias;
@Size(max = 5000)
private String notes;
private LocalDate birthDate;
private DatePrecision birthDatePrecision;
private LocalDate deathDate;
private DatePrecision deathDatePrecision;
private Integer birthYear;
private Integer deathYear;
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
// PersonGeneration so DB, DTO, and importer all read from one place.
@Min(PersonGeneration.MIN_GENERATION)

View File

@@ -96,9 +96,7 @@ public class RelationshipInferenceService {
if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(),
RelationshipService.yearOf(p.getBirthDate()),
RelationshipService.yearOf(p.getDeathDate()),
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
p.getGeneration(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
}

View File

@@ -15,7 +15,6 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -67,8 +66,7 @@ public class RelationshipService {
for (Person p : familyMembers) {
familyIds.add(p.getId());
nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(),
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
p.getGeneration(), true));
}
@@ -86,15 +84,6 @@ public class RelationshipService {
return new NetworkDTO(nodes, edges);
}
/**
* Returns all {@code SPOUSE_OF} edges with both person sides JOIN FETCHed.
* Used by {@code TimelineService.assembleDerivedEvents()} to build Heirat events
* without per-edge N+1 queries.
*/
public List<PersonRelationship> findAllSpouseEdges() {
return relationshipRepository.findAllByRelationTypeIn(List.of(RelationType.SPOUSE_OF));
}
@Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) {
@@ -166,13 +155,6 @@ public class RelationshipService {
return (s == null || s.isBlank()) ? null : s.trim();
}
// Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
// for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). Package-private
// so RelationshipInferenceService shares the same derivation.
static Integer yearOf(LocalDate date) {
return date != null ? date.getYear() : null;
}
private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest(
@@ -188,11 +170,11 @@ public class RelationshipService {
p.getId(),
rp.getId(),
p.getDisplayName(),
yearOf(p.getBirthDate()),
yearOf(p.getDeathDate()),
p.getBirthYear(),
p.getDeathYear(),
rp.getDisplayName(),
yearOf(rp.getBirthDate()),
yearOf(rp.getDeathDate()),
rp.getBirthYear(),
rp.getDeathYear(),
r.getRelationType(),
r.getFromYear(),
r.getToYear(),

View File

@@ -1,8 +0,0 @@
package org.raddatz.familienarchiv.timeline;
/** Discriminator for derived life-events assembled from Person / PersonRelationship data. */
public enum DerivedEventType {
BIRTH,
DEATH,
MARRIAGE
}

View File

@@ -1,17 +0,0 @@
package org.raddatz.familienarchiv.timeline;
/**
* Kind of a curated {@link TimelineEvent}.
*
* <p>The string value names are a <strong>stable frontend styling contract</strong>: the
* Svelte timeline components hard-code {@code "PERSONAL"} (family accent) and
* {@code "HISTORICAL"} (muted world accent) as Tailwind class-map keys. There is no
* mapping layer — renaming either value requires a coordinated frontend change. See
* ADR-040.
*/
public enum EventType {
/** A family/personal event (birth, wedding, move) — rendered with the family accent. */
PERSONAL,
/** A world/historical event providing context — rendered with the muted world accent. */
HISTORICAL
}

View File

@@ -1,7 +0,0 @@
package org.raddatz.familienarchiv.timeline;
/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */
public enum Kind {
EVENT,
LETTER
}

View File

@@ -1,33 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/timeline")
@Validated
@RequiredArgsConstructor
public class TimelineController {
private final TimelineService timelineService;
@GetMapping
@RequirePermission(Permission.READ_ALL)
public TimelineDTO getTimeline(
@RequestParam(required = false) UUID personId,
@RequestParam(required = false) @Min(0) Integer generation,
@RequestParam(required = false) EventType type,
@RequestParam(required = false) Integer fromYear,
@RequestParam(required = false) Integer toYear) {
return timelineService.assemble(new TimelineFilter(personId, generation, type, fromYear, toYear));
}
}

View File

@@ -1,15 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/**
* Assembled timeline response. Year bands are sorted ascending (oldest first).
* Undated entries have no usable date or {@code UNKNOWN} precision.
*/
public record TimelineDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineYearDTO> years,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> undated
) {
}

View File

@@ -1,42 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Unified DTO for timeline entries — covers curated {@link TimelineEvent} rows, derived
* life-events ({@link DerivedEventType}), and archive letters (Documents).
*
* <p><b>Edit-affordance contract (for issue #7):</b> {@code derived == true || eventId == null}
* means no edit link should be rendered by the frontend.
*
* <p><b>Letter display fields:</b> {@code senderName} — {@code ""} means unknown/unlinked
* correspondent; frontend renders {@code 'Unbekannt'} fallback. Only populated for
* {@link Kind#LETTER} entries.
*
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
* an event-type badge for letters.
*
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
*/
public record TimelineEntryDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Kind kind,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String senderName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String receiverName,
LocalDate eventDate,
LocalDate eventDateEnd,
String title,
EventType type,
UUID eventId,
UUID documentId,
List<UUID> linkedPersonIds,
DerivedEventType derivedType
) {
}

View File

@@ -1,134 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* A curated event on the family timeline (Zeitstrahl). Unlike a {@link Document}, which is
* OCR-derived, a {@code TimelineEvent} is authored by curators — hence the optimistic-lock
* {@link #version} and the {@link #createdBy}/{@link #updatedBy} audit trail that
* {@code Document} lacks.
*
* <p>The date block ({@link #eventDate}, {@link #precision}, {@link #eventDateEnd}) mirrors
* {@code Document}'s so events and letters share one rendering path. The mirror applies to
* the date block only — the audit footprint deliberately diverges (see ADR-040).
*/
@Entity
@Table(name = "timeline_events")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TimelineEvent {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String title;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private EventType type;
/** The most precise date known for the event. Always present — a curated event is never undated. */
@Column(name = "event_date", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate eventDate;
/**
* Precision of {@link #eventDate}. Reuses {@code document.DatePrecision} (one rendering
* path; see ADR-025 / ADR-040). Every value except {@code UNKNOWN} is legal for a curated
* event — including {@code SEASON} ("Sommer 1914") and {@code APPROX} ("ca. 1914"). The DB
* CHECK forbids exactly {@code UNKNOWN}; do not narrow it further.
*/
@Enumerated(EnumType.STRING)
@Column(name = "date_precision", nullable = false, length = 16)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private DatePrecision precision = DatePrecision.YEAR;
/** Range end — non-null <strong>iff</strong> {@link #precision} is {@code RANGE} (DB CHECK, both directions). */
@Column(name = "event_date_end")
private LocalDate eventDateEnd;
@Column(columnDefinition = "TEXT")
private String description;
/** People the event involves. */
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "timeline_event_persons",
joinColumns = @JoinColumn(name = "timeline_event_id"),
inverseJoinColumns = @JoinColumn(name = "person_id"))
@BatchSize(size = 50)
@Builder.Default
private Set<Person> persons = new HashSet<>();
/** Optional supporting letters linked to the event. */
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "timeline_event_documents",
joinColumns = @JoinColumn(name = "timeline_event_id"),
inverseJoinColumns = @JoinColumn(name = "document_id"))
@BatchSize(size = 50)
@Builder.Default
private Set<Document> documents = new HashSet<>();
/**
* UUID of the {@code AppUser} who created the event. Bare UUID, no FK to {@code app_users}
* (sidecar pattern — keeps {@code timeline} decoupled from {@code user}). Server-populated
* from the session principal; never accepted from client input (authorship-forgery vector,
* CWE-639 — see ADR-040).
*/
@Column(name = "created_by", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID createdBy;
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
/**
* UUID of the {@code AppUser} who last edited the event. Populated from the session
* principal in {@code TimelineEventService}; <strong>must be set before every
* {@code save()}</strong> — {@code @UpdateTimestamp} on {@link #updatedAt} does NOT set this
* automatically, so without an explicit set the timestamp advances while the "who" goes
* stale. Same forgery rationale as {@link #createdBy}.
*/
@Column(name = "updated_by", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID updatedBy;
@UpdateTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updatedAt;
/**
* Optimistic-lock version for the multi-curator edit flow (#775). Object {@code Long}
* (not primitive) so it is {@code null} before first persist; Hibernate sets {@code 0} on
* insert. A concurrent-write conflict must be translated to {@code DomainException.conflict}
* in the service layer (ADR-040) — otherwise it surfaces as HTTP 500 with Hibernate
* internals (CWE-209).
*/
@Version
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Long version;
}

View File

@@ -1,71 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/timeline/events")
@RequiredArgsConstructor
public class TimelineEventController {
private final TimelineEventService timelineEventService;
private final UserService userService;
/**
* No {@code @RequirePermission} on GET by design: the global {@code anyRequest().authenticated()}
* rule is the READ_ALL baseline, consistent with {@code DocumentController.getDocument}. Do not
* "fix" the missing annotation.
*/
@GetMapping("/{id}")
public TimelineEventView getEvent(@PathVariable UUID id) {
return timelineEventService.getEvent(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.WRITE_ALL)
public TimelineEventView create(@Valid @RequestBody TimelineEventRequest request, Authentication authentication) {
return timelineEventService.create(request, requireUserId(authentication));
}
@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public TimelineEventView update(
@PathVariable UUID id,
@Valid @RequestBody TimelineEventRequest request,
Authentication authentication) {
return timelineEventService.update(id, request, requireUserId(authentication));
}
@DeleteMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Void> delete(@PathVariable UUID id) {
timelineEventService.delete(id);
return ResponseEntity.noContent().build();
}
private UUID requireUserId(Authentication authentication) {
return SecurityUtils.requireUserId(authentication, userService);
}
}

View File

@@ -1,9 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface TimelineEventRepository extends JpaRepository<TimelineEvent, UUID> {
// TODO(#777): findByPersonsContaining(Person) needed for the per-person filter
}

View File

@@ -1,40 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Flat input DTO for creating/updating a {@link TimelineEvent}. Bean Validation fires at the
* controller boundary (via {@code @Valid}) and produces a 400 {@code VALIDATION_ERROR} for the
* presence/size constraints below; cross-field rules (the RANGE invariant), date normalization,
* id dedupe, and the title-length structured-error guard live in {@code TimelineEventService}.
*
* <p><strong>{@code createdBy}/{@code updatedBy} are intentionally absent.</strong> Authorship is
* server-populated from the session principal only — accepting it from the body would be an
* authorship-forgery / mass-assignment vector (CWE-639; see ADR-040 §7).
*
* @param version optional optimistic-lock concurrency token (the {@code @Version} the client last
* saw), applied on <em>update</em> only. This is a concurrency token, <strong>not</strong>
* an authorship field, so it is deliberately exempt from the §7 server-only audit rule.
* Null on update means "no concurrency check" (last-write-wins). No range validation —
* a stale/negative value is simply a mismatch the lock rejects at flush; the lock, not
* a validator, is the control.
*/
public record TimelineEventRequest(
@NotBlank @Size(max = 255) String title,
@NotNull EventType type,
@NotNull LocalDate eventDate,
DatePrecision precision,
LocalDate eventDateEnd,
@Size(max = 5000) String description,
Long version,
@Size(max = 50) List<UUID> personIds,
@Size(max = 50) List<UUID> documentIds
) {}

View File

@@ -1,327 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Curator CRUD for {@link TimelineEvent}. Persons and documents are resolved through their own
* services (never their repositories). All four body-returning operations return a
* {@link TimelineEventView} assembled in-transaction — the entity is never serialized (ADR-040 §2).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TimelineEventService {
private static final int MAX_TITLE_LENGTH = 255;
private final TimelineEventRepository events;
private final PersonService personService;
private final DocumentService documentService;
private final RelationshipService relationshipService;
@Transactional
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
DatePrecision precision = effectivePrecision(request);
validateRangeInvariant(request, precision);
validateTitleLength(request);
TimelineEvent event = TimelineEvent.builder()
.title(request.title())
.type(request.type())
.eventDate(normalizeEventDate(request.eventDate(), precision))
.precision(precision)
.eventDateEnd(request.eventDateEnd())
.description(request.description())
.persons(resolvePersons(request.personIds()))
.documents(resolveDocuments(request.documentIds()))
.createdBy(actorId)
.updatedBy(actorId)
.build();
return toView(events.saveAndFlush(event));
}
@Transactional
public TimelineEventView update(UUID id, TimelineEventRequest request, UUID actorId) {
TimelineEvent event = events.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
"Timeline event not found: " + id));
requireVersionMatch(request, event);
DatePrecision precision = effectivePrecision(request);
validateRangeInvariant(request, precision);
validateTitleLength(request);
applyUpdate(event, request, precision, actorId);
// saveAndFlush (not save) so the versioned UPDATE …WHERE version=? fires HERE, inside the
// try — a bare save() flushes at commit, after this method returns, so the exception would
// escape the catch and surface as a 500. Catch the Spring-translated type, not JPA's.
try {
return toView(events.saveAndFlush(event));
} catch (ObjectOptimisticLockingFailureException ex) {
throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
"Timeline event was modified concurrently: " + id);
}
}
@Transactional
public void delete(UUID id) {
TimelineEvent event = events.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
"Timeline event not found: " + id));
events.delete(event);
}
/**
* View-assembly read. {@code @Transactional(readOnly = true)} is load-bearing, not optional:
* the LAZY {@code persons}/{@code documents} collections are traversed during {@link #toView}
* assembly, and under {@code open-in-view: false} a closed session there is a
* {@code LazyInitializationException} (ADR-022 / {@code getDocumentDetail} precedent).
*/
@Transactional(readOnly = true)
public TimelineEventView getEvent(UUID id) {
TimelineEvent event = events.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
"Timeline event not found: " + id));
return toView(event);
}
// --- update mechanics: mutate the managed entity, never reassign collections ---
private void applyUpdate(TimelineEvent event, TimelineEventRequest request, DatePrecision precision, UUID actorId) {
event.setTitle(request.title());
event.setType(request.type());
event.setEventDate(normalizeEventDate(request.eventDate(), precision));
event.setPrecision(precision);
event.setEventDateEnd(request.eventDateEnd());
event.setDescription(request.description());
replaceLinks(event, request);
event.setUpdatedBy(actorId); // preserve createdBy — only the editor changes
}
/**
* Compares the client's concurrency token against the freshly-loaded version (the Q1
* "last-seen version" token). A mismatch means the client edited stale data → 409.
*
* <p>This explicit compare is the control — NOT {@code event.setVersion(clientVersion)} before
* flush. Setting {@code @Version} on a <em>managed</em> entity is silently ignored by Hibernate
* for the optimistic check: it uses its own loaded-version snapshot for the
* {@code UPDATE … WHERE version=?} clause, so a stale token never reaches the DB. The native
* {@code @Version} increment still happens on every save, and the {@code saveAndFlush}+catch
* below remains the backstop for two transactions flushing concurrently; this guard is what
* catches the human-timescale "B submitted a form based on a version A already superseded" case.
* A null token means no check (last-write-wins) until #9 always sends it.
*/
private void requireVersionMatch(TimelineEventRequest request, TimelineEvent event) {
if (request.version() != null && !request.version().equals(event.getVersion())) {
throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
"Timeline event was modified concurrently: " + event.getId());
}
}
/**
* Replaces (set semantics) the link collections. Mutates the existing managed collections —
* Hibernate does not track a reassigned reference, and a fresh {@code Set} risks orphan join
* rows against the {@code ON DELETE CASCADE} join tables. A null or empty list clears all links.
*/
private void replaceLinks(TimelineEvent event, TimelineEventRequest request) {
event.getPersons().clear();
event.getPersons().addAll(resolvePersons(request.personIds()));
event.getDocuments().clear();
event.getDocuments().addAll(resolveDocuments(request.documentIds()));
}
// --- validation / normalization ---
/**
* Mirrors the DB biconditional CHECK chk_timeline_event_range — both presence directions — and
* additionally enforces date ordering, which the DB CHECK does NOT: {@code eventDateEnd} may
* equal but never precede {@code eventDate}. Without this guard a reversed range (end before
* start) persists silently and renders as a negative span. Equal dates are a valid one-day
* closed range.
*/
private void validateRangeInvariant(TimelineEventRequest request, DatePrecision precision) {
boolean isRange = precision == DatePrecision.RANGE;
if (request.eventDateEnd() != null && !isRange) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
"eventDateEnd is only valid when precision is RANGE");
}
if (isRange && request.eventDateEnd() == null) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
"A RANGE event requires a non-null eventDateEnd");
}
if (isRange && request.eventDateEnd().isBefore(request.eventDate())) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
"eventDateEnd must not precede eventDate");
}
}
/**
* Load-bearing only for non-HTTP callers: the DTO {@code @Size(max = 255)} already covers HTTP
* callers, but a non-HTTP caller could otherwise push an over-long title to the VARCHAR(255)
* column and get a raw {@code DataIntegrityViolationException} → 500. Do not delete as
* "duplicate validation".
*/
private void validateTitleLength(TimelineEventRequest request) {
if (request.title() != null && request.title().length() > MAX_TITLE_LENGTH) {
throw DomainException.badRequest(ErrorCode.TIMELINE_TITLE_TOO_LONG,
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
}
}
private DatePrecision effectivePrecision(TimelineEventRequest request) {
return request.precision() != null ? request.precision() : DatePrecision.YEAR;
}
private LocalDate normalizeEventDate(LocalDate eventDate, DatePrecision precision) {
return precision == DatePrecision.YEAR ? LocalDate.of(eventDate.getYear(), 1, 1) : eventDate;
}
// --- link resolution (fail-closed, dedupe-first) ---
private Set<Person> resolvePersons(List<UUID> ids) {
if (ids == null || ids.isEmpty()) {
return new HashSet<>();
}
// Dedupe FIRST: [idA, idA] is one link, not a 404. findAllById dedupes too, so compare the
// resolved size against the DISTINCT input count — a raw ids.size() compare reports a spurious
// mismatch.
Set<UUID> distinct = new LinkedHashSet<>(ids);
List<Person> resolved = personService.getAllById(new ArrayList<>(distinct));
if (resolved.size() != distinct.size()) {
throw DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found");
}
return new HashSet<>(resolved);
}
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) {
return new HashSet<>();
}
// Per-id loop on purpose: DocumentService has no batch fetch, and per-id gives free
// DOCUMENT_NOT_FOUND 404s. getDocumentById is @Transactional(readOnly = true) and joins this
// write tx via Spring's default REQUIRED propagation — do NOT "optimize" into a phantom batch.
Set<Document> resolved = new HashSet<>();
for (UUID documentId : new LinkedHashSet<>(ids)) {
resolved.add(documentService.getDocumentById(documentId));
}
return resolved;
}
// --- derived event assembly ---
/**
* Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and
* PersonRelationship data. Computed on read, never persisted.
*
* <p>Derived events are computed, never persisted, and cannot be mutated via the events API
* (enforced in #5). Ids produced by this method are structurally non-UUID
* ({@code birth:*}, {@code death:*}, {@code marriage:*}) and MUST be rejected by any
* write endpoint — enforced and tested in #5. Callers outside the #5 endpoint must
* independently enforce {@code READ_ALL} authorization before invoking this method
* (see ADR-043).
*/
@Transactional(readOnly = true)
public List<TimelineEntryDTO> assembleDerivedEvents() {
List<Person> persons = personService.findAllFamilyMembers();
List<PersonRelationship> spouseEdges = relationshipService.findAllSpouseEdges();
List<TimelineEntryDTO> result = new ArrayList<>();
result.addAll(buildBirthEvents(persons));
result.addAll(buildDeathEvents(persons));
result.addAll(buildMarriageEvents(spouseEdges));
log.debug("Assembled {} derived events for {} persons", result.size(), persons.size());
return result;
}
private List<TimelineEntryDTO> buildBirthEvents(List<Person> persons) {
return persons.stream()
.filter(p -> p.getBirthDate() != null)
.map(p -> new TimelineEntryDTO(
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
p.getBirthDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.BIRTH))
.toList();
}
private List<TimelineEntryDTO> buildDeathEvents(List<Person> persons) {
return persons.stream()
.filter(p -> p.getDeathDate() != null)
.map(p -> new TimelineEntryDTO(
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
p.getDeathDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.DEATH))
.toList();
}
private List<TimelineEntryDTO> buildMarriageEvents(List<PersonRelationship> spouseEdges) {
// DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
// in-memory dedup on relationship row id is a defensive assertion.
Set<UUID> seen = new HashSet<>();
List<TimelineEntryDTO> result = new ArrayList<>();
for (PersonRelationship r : spouseEdges) {
if (seen.add(r.getId())) {
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
LocalDate eventDate = r.getFromYear() != null
? LocalDate.of(r.getFromYear(), 1, 1)
: null;
DatePrecision precision = r.getFromYear() != null
? DatePrecision.YEAR
: DatePrecision.UNKNOWN;
String title = r.getPerson().getDisplayName()
+ " & " + r.getRelatedPerson().getDisplayName();
result.add(new TimelineEntryDTO(
Kind.EVENT, precision, true, "", "",
eventDate, null,
title, EventType.PERSONAL,
null, null,
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
DerivedEventType.MARRIAGE));
}
}
return result;
}
// --- view assembly (explicit allow-list; never the raw entity) ---
private TimelineEventView toView(TimelineEvent event) {
List<PersonView> persons = event.getPersons().stream()
.map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName()))
.toList();
List<DocumentRef> documents = event.getDocuments().stream()
.map(d -> new DocumentRef(d.getId(), d.getTitle(), d.getDocumentDate()))
.toList();
return new TimelineEventView(
event.getId(), event.getTitle(), event.getType(), event.getEventDate(),
event.getPrecision(), event.getEventDateEnd(), event.getDescription(), event.getVersion(),
event.getCreatedBy(), event.getCreatedAt(), event.getUpdatedBy(), event.getUpdatedAt(),
persons, documents);
}
}

View File

@@ -1,59 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Response view for the timeline event endpoints — returned by GET, POST, and PUT alike.
* Assembled inside the service transaction (after {@code saveAndFlush} on the write paths, so
* {@code version} is non-null) from the managed entity's already-loaded collections. The raw
* {@link TimelineEvent} is never serialized: its LAZY {@code persons}/{@code documents}
* collections under {@code open-in-view: false} would otherwise 500 (ADR-036/ADR-040 §2), and
* splatting the entities would leak curator-internal fields ({@code Person.notes},
* {@code provisional}, transcription data) to every READ_ALL reader. The explicit field
* allow-list below is that guarantee.
*/
public record TimelineEventView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) EventType type,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDate eventDate,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
LocalDate eventDateEnd,
String description,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Long version,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID createdBy,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID updatedBy,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonView> persons,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<DocumentRef> documents
) {
/** Summarised person — exposes only id, firstName, and lastName. Mirrors GeschichteView.PersonView. */
public record PersonView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
String firstName,
String lastName
) {}
/**
* Summarised linked document — id, title, and the eager {@code documentDate} only (no lazy
* sender/receiver hop, no person-name leak through the document side).
*
* <p>timeline-local by design; do not promote to {@code document/} — see #775 R7. Reusing
* {@code geschichte.journeyitem.DocumentSummary} would force a cross-domain import of a
* package-private mapper plus duplicated name-assembly logic; a 3-field local record is the
* lower-coupling choice.
*/
public record DocumentRef(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
LocalDate documentDate
) {}
}

View File

@@ -1,16 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import java.util.UUID;
/**
* Immutable filter bag for {@link TimelineService#assemble(TimelineFilter)}.
* All fields are nullable — null means "no constraint on this dimension".
*/
public record TimelineFilter(
UUID personId,
Integer generation,
EventType type,
Integer fromYear,
Integer toYear
) {
}

View File

@@ -1,268 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Assembles the family timeline from three sources — curated {@link TimelineEvent} rows,
* derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}.
*
* <p>Cross-domain data is reached exclusively through domain services (PersonService,
* DocumentService). The only repository injected directly is {@link TimelineEventRepository}
* (same domain — constitution §1.3).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TimelineService {
/** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */
static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER =
Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed()
.thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX)
.thenComparing(e -> e.title() != null ? e.title() : "")
.thenComparing(e -> {
if (e.eventId() != null) return e.eventId().toString();
if (e.documentId() != null) return e.documentId().toString();
return "";
});
private final TimelineEventRepository eventRepository;
private final TimelineEventService timelineEventService;
private final DocumentService documentService;
private final PersonService personService;
/**
* Assembles the timeline for the given filter. All filters are ANDed.
* Throws {@link DomainException} (bad request) when fromYear &gt; toYear.
* Throws {@link DomainException} (not found) when personId refers to an unknown person.
*
* <p>{@code @Transactional(readOnly=true)} is required here — unlike simple scalar reads,
* this method accesses lazy collections ({@link TimelineEvent#getPersons()},
* {@link org.raddatz.familienarchiv.document.Document#getReceivers()}) after the
* repository sub-transaction closes. Without this annotation those accesses throw
* {@link org.hibernate.LazyInitializationException} in production (constitution §1.6).
*/
@Transactional(readOnly = true)
public TimelineDTO assemble(TimelineFilter filter) {
if (filter.fromYear() != null && filter.toYear() != null
&& filter.fromYear() > filter.toYear()) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"toYear must not be before fromYear");
}
// Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
// ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>();
for (TimelineEvent ev : eventRepository.findAll()) {
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
entries.add(mapEvent(ev));
}
// ── derived events ───────────────────────────────────────────────────
for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) {
if (!passesTypeFilter(derived.type(), filter.type())) continue;
if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue;
if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue;
if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue;
entries.add(derived);
}
// ── letters ─────────────────────────────────────────────────────────
List<Document> docs = fetchDocuments(filter.personId());
for (Document doc : docs) {
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
entries.add(mapDocument(doc));
}
return bucket(entries);
}
// ─── Bucketing ───────────────────────────────────────────────────────────
Map<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
Map<Integer, List<TimelineEntryDTO>> map = new TreeMap<>();
for (TimelineEntryDTO e : entries) {
if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue;
map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e);
}
return map;
}
private TimelineDTO bucket(List<TimelineEntryDTO> entries) {
List<TimelineEntryDTO> undated = entries.stream()
.filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
.sorted(WITHIN_BAND_ORDER)
.toList();
Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
List<TimelineYearDTO> years = byYear.entrySet().stream()
.map(e -> new TimelineYearDTO(e.getKey(),
e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList()))
.toList();
return new TimelineDTO(years, undated);
}
// ─── Document fetch (global vs personId path) ────────────────────────────
private List<Document> fetchDocuments(UUID personId) {
if (personId == null) {
return documentService.getAllForTimeline();
}
// personId path: validate existence, then union sender+receiver (dedup by id)
personService.getById(personId);
Map<UUID, Document> seen = new LinkedHashMap<>();
for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d);
for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d);
return new ArrayList<>(seen.values());
}
// ─── Filter predicates ───────────────────────────────────────────────────
private boolean passesTypeFilter(EventType entryType, EventType filterType) {
return filterType == null || filterType == entryType;
}
private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) {
if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes
int year = date.getYear();
if (filter.fromYear() != null && year < filter.fromYear()) return false;
if (filter.toYear() != null && year > filter.toYear()) return false;
return true;
}
private boolean passesPersonFilter(Set<Person> persons, UUID personId) {
if (personId == null) return true;
return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
}
private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
if (personId == null) return true;
return linkedIds != null && linkedIds.contains(personId);
}
private Set<UUID> resolveGenerationPersonIds(Integer generation) {
if (generation == null) return null;
return personService.getPersonsByGeneration(generation).stream()
.map(Person::getId)
.collect(Collectors.toSet());
}
private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
if (persons == null || persons.isEmpty()) return false;
return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId()));
}
private boolean passesDerivedGenerationFilter(List<UUID> linkedIds, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
if (linkedIds == null || linkedIds.isEmpty()) return false;
return linkedIds.stream().anyMatch(genPersonIds::contains);
}
private boolean passesLetterGenerationFilter(Document doc, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
Person sender = doc.getSender();
if (sender != null && genPersonIds.contains(sender.getId())) return true;
Set<Person> receivers = doc.getReceivers();
if (receivers != null) {
return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
}
return false;
}
// ─── Mapping ─────────────────────────────────────────────────────────────
private TimelineEntryDTO mapEvent(TimelineEvent ev) {
List<UUID> personIds = ev.getPersons() == null ? List.of()
: ev.getPersons().stream().map(Person::getId).toList();
return new TimelineEntryDTO(
Kind.EVENT,
ev.getPrecision(),
false,
"",
"",
ev.getEventDate(),
ev.getEventDateEnd(),
ev.getTitle(),
ev.getType(),
ev.getId(),
null,
personIds,
null
);
}
private TimelineEntryDTO mapDocument(Document doc) {
return new TimelineEntryDTO(
Kind.LETTER,
doc.getMetaDatePrecision(),
false,
resolveSenderName(doc),
resolveReceiverName(doc),
doc.getDocumentDate(),
null,
doc.getTitle(),
null,
null,
doc.getId(),
List.of(),
null
);
}
private String resolveSenderName(Document doc) {
if (doc.getSender() != null) return doc.getSender().getDisplayName();
String text = doc.getSenderText();
return (text != null && !text.isBlank()) ? text : "";
}
private String resolveReceiverName(Document doc) {
Set<Person> receivers = doc.getReceivers();
if (receivers != null && !receivers.isEmpty()) {
return receivers.stream().findFirst().map(Person::getDisplayName).orElse("");
}
String text = doc.getReceiverText();
return (text != null && !text.isBlank()) ? text : "";
}
private static int precisionRank(DatePrecision precision) {
if (precision == null) return 0;
return switch (precision) {
case DAY -> 5;
case MONTH -> 4;
case SEASON -> 3;
case YEAR -> 2;
case APPROX -> 1;
default -> 0;
};
}
}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** One year's worth of timeline entries, sorted by {@link TimelineService#WITHIN_BAND_ORDER}. */
public record TimelineYearDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int year,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> entries
) {
}

View File

@@ -1,42 +0,0 @@
-- V76: persons.birth_year/death_year (integer) → birth_date/death_date (date)
-- plus NOT NULL precision columns mirroring documents.meta_date_precision.
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-039).
-- One-way migration: rollback is a targeted pg_restore -t persons from the
-- pre-deploy backup (see docs/DEPLOYMENT.md).
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
-- before any DDL runs. Single-writer family archive, so no race window matters.
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year)
THEN RAISE EXCEPTION 'V76 aborted: % persons have birth_year > death_year — fix data before migrating',
(SELECT COUNT(*) FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year);
END IF;
IF EXISTS (SELECT 1 FROM persons WHERE birth_year = 0 OR death_year = 0)
THEN RAISE EXCEPTION 'V76 aborted: persons table contains birth_year=0 or death_year=0 rows — clean data before migrating';
END IF;
END $$;
ALTER TABLE persons ADD COLUMN birth_date date;
ALTER TABLE persons ADD COLUMN birth_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
ALTER TABLE persons ADD COLUMN death_date date;
ALTER TABLE persons ADD COLUMN death_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR'
WHERE birth_year IS NOT NULL;
UPDATE persons SET death_date = make_date(death_year, 1, 1), death_date_precision = 'YEAR'
WHERE death_year IS NOT NULL;
-- Named constraints: readable Postgres error messages when violated.
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_before_death
CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date);
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_coherence
CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_values
CHECK (birth_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_coherence
CHECK ((death_date IS NULL) = (death_date_precision = 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_values
CHECK (death_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
ALTER TABLE persons DROP COLUMN birth_year;
ALTER TABLE persons DROP COLUMN death_year;

View File

@@ -1,64 +0,0 @@
-- V77: timeline domain foundation (Zeitstrahl) — curated timeline events.
-- Forward-only, additive DDL. No rollback script: rollback requires manual DROP TABLE
-- (timeline_event_documents, timeline_event_persons, then timeline_events). See ADR-040.
--
-- The date block (event_date / date_precision / event_date_end) mirrors documents' so events
-- and letters share one rendering path. Two divergences from documents are INTENTIONAL and
-- enforced here in Postgres (ADR-040):
-- 1. The RANGE rule is a strict biconditional (event_date_end non-null IFF RANGE), unlike
-- documents' open-ended ranges — a curated event always has a known, closed end.
-- 2. date_precision <> 'UNKNOWN' — only OCR-inferred letters are ever undated; a curated
-- event always has at least a year. SEASON and APPROX stay legal (Sommer/ca. 1914).
CREATE TABLE timeline_events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
type VARCHAR(16) NOT NULL,
event_date DATE NOT NULL,
date_precision VARCHAR(16) NOT NULL DEFAULT 'YEAR',
event_date_end DATE,
description TEXT,
created_by UUID NOT NULL,
created_at TIMESTAMP,
updated_by UUID NOT NULL,
updated_at TIMESTAMP,
version BIGINT,
CONSTRAINT pk_timeline_events PRIMARY KEY (id),
-- event_date_end is non-null IFF precision is RANGE (both directions).
CONSTRAINT chk_timeline_event_range
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL)),
-- Curated events are never undated. Forbids exactly UNKNOWN — every other
-- DatePrecision value (DAY, MONTH, SEASON, YEAR, RANGE, APPROX) stays legal.
CONSTRAINT chk_timeline_event_precision
CHECK (date_precision <> 'UNKNOWN')
);
-- Join table: events ↔ persons involved.
CREATE TABLE timeline_event_persons (
timeline_event_id UUID NOT NULL,
person_id UUID NOT NULL,
CONSTRAINT pk_timeline_event_persons PRIMARY KEY (timeline_event_id, person_id),
CONSTRAINT fk_tep_event
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
CONSTRAINT fk_tep_person
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
);
-- Join table: events ↔ supporting letters.
CREATE TABLE timeline_event_documents (
timeline_event_id UUID NOT NULL,
document_id UUID NOT NULL,
CONSTRAINT pk_timeline_event_documents PRIMARY KEY (timeline_event_id, document_id),
CONSTRAINT fk_ted_event
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
CONSTRAINT fk_ted_document
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
-- Indexes added up-front (avoid the V62 FK-index retrofit debt): the two query columns plus
-- the inverse-side FK columns. timeline_event_id needs no extra index on either join table —
-- it is the leading column of the composite PK, so the PK index already serves those lookups.
CREATE INDEX idx_timeline_events_event_date ON timeline_events (event_date);
CREATE INDEX idx_timeline_events_type ON timeline_events (type);
CREATE INDEX idx_timeline_event_persons_person_id ON timeline_event_persons (person_id);
CREATE INDEX idx_timeline_event_documents_document_id ON timeline_event_documents (document_id);

View File

@@ -2943,17 +2943,4 @@ class DocumentServiceTest {
assertThat(result.buckets()).isEmpty();
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
}
// --- getAllForTimeline ---
@Test
void getAllForTimeline_delegates_bulk_fetch_to_repository() {
Document doc = Document.builder().id(UUID.randomUUID()).title("Brief").build();
when(documentRepository.findAllForTimeline()).thenReturn(List.of(doc));
List<Document> result = documentService.getAllForTimeline();
assertThat(result).containsExactly(doc);
verify(documentRepository).findAllForTimeline();
}
}

View File

@@ -14,9 +14,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic;
@@ -106,49 +103,6 @@ class GlobalExceptionHandlerTest {
});
}
@Test
void handleOptimisticLock_returns409_genericConflict_noSentry_noLeak() {
// CWE-209 regression: an optimistic-lock failure escaping a service catch must become a
// generic 409, never a 500 + Sentry + Hibernate internals. The generic CONFLICT code keeps
// it entity-agnostic — NOT TIMELINE_EVENT_CONFLICT, or every future entity's conflict is
// mislabeled a timeline one.
UUID entityId = UUID.randomUUID();
ObjectOptimisticLockingFailureException ex =
new ObjectOptimisticLockingFailureException("com.example.SomeEntity", entityId);
Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
handlerLogger.addAppender(appender);
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleOptimisticLock(ex);
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ErrorCode.CONFLICT);
// Body echoes no persistent-class name, no entity id, no version (enumeration aids).
assertThat(response.getBody().message())
.doesNotContain("SomeEntity")
.doesNotContain(entityId.toString());
// A conflict is not a system fault — no fabricated Sentry alert.
sentryMock.verifyNoInteractions();
} finally {
handlerLogger.detachAppender(appender);
}
assertThat(appender.list)
.as("WARN names the persistent class for debuggability")
.anySatisfy(e -> {
assertThat(e.getLevel()).isEqualTo(Level.WARN);
assertThat(e.getFormattedMessage()).contains("com.example.SomeEntity");
});
assertThat(appender.list)
.as("but never the entity id (would leak via getMessage())")
.noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains(entityId.toString()));
}
@Test
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
// Debuggability (DevOps): the WARN must name *which* constraint fired so an

View File

@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -36,7 +35,6 @@ 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.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -230,7 +228,21 @@ class GeschichteServiceTest {
geschichteService.list(null, List.of(), null, 50);
verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
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);
GeschichteSummary s1 = mock(GeschichteSummary.class);
GeschichteSummary s2 = mock(GeschichteSummary.class);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(s1, s2));
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
assertThat(out).hasSize(2);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
@@ -270,21 +282,6 @@ class GeschichteServiceTest {
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
}
@Test
void list_passes_nil_uuid_sentinel_to_repository_when_no_person_filter_given() {
// B2: when personIds is empty/null the service must pass a sentinel NIL UUID
// so the IN() predicate is skipped without producing invalid empty-IN() SQL.
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), null, 50);
UUID nilUUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
verify(geschichteRepository).findSummaries(
any(), any(), org.mockito.ArgumentMatchers.argThat(ids -> ids.contains(nilUUID)), anyLong(), any());
}
@Test
void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
@@ -296,33 +293,6 @@ class GeschichteServiceTest {
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
@Test
@DisplayName("security: null status for blog writer returns PUBLISHED, never leaks drafts")
void list_with_blog_writer_and_null_status_returns_PUBLISHED_not_all_drafts() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), null, 50);
verify(geschichteRepository).findSummaries(
eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
}
@Test
@DisplayName("security: DRAFT status scopes to current user only")
void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
verify(geschichteRepository).findSummaries(
eq(GeschichteStatus.DRAFT), eq(writer.getId()), any(), anyLong(), any());
}
// ─── create ──────────────────────────────────────────────────────────────
@Test

View File

@@ -25,9 +25,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -351,22 +348,19 @@ class JourneyItemServiceTest {
}
@Test
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
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.
// Uses PSQLException with SQLState 23505 — the real payload Postgres delivers.
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));
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"could not execute statement", psqlEx));
"duplicate key value violates unique constraint \"uq_journey_items_geschichte_document\""));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
@@ -378,48 +372,18 @@ class JourneyItemServiceTest {
}
@Test
void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// B1: the dedup check must use PSQLException.getSQLState() == "23505", not
// constraint-name string matching — constraint renames must not regress this.
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));
// Simulate a real Postgres unique-violation: PSQLException with SQLState 23505
// wrapped by Spring's DataIntegrityViolationException.
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
org.springframework.dao.DataIntegrityViolationException dive =
new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx);
when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive);
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_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception {
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() {
// An FK violation (document deleted between load and flush) must NOT be
// translated into "already added" — only the dedup unique index (23505) earns that 409.
// FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation).
// translated into "already added" — only the dedup index earns that 409.
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));
PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"could not execute statement", psqlEx));
"insert or update on table \"journey_items\" violates foreign key constraint \"fk_journey_items_document\""));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());

View File

@@ -1,244 +0,0 @@
package org.raddatz.familienarchiv.person;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Verifies V76: persons.birth_year/death_year (integer) become
* birth_date/death_date (date) + *_date_precision columns, with backfill to
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
* pre-check that aborts the migration on corrupt year data.
*
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
* database so the staged migrate-to-V75 → seed → migrate-to-latest flow and
* the abort cases cannot interfere with each other.
*/
class PersonBirthDeathMigrationTest {
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
private String dbUrl;
@BeforeAll
static void startContainer() {
POSTGRES.start();
}
@AfterAll
static void stopContainer() {
POSTGRES.stop();
}
@BeforeEach
void createFreshDatabase() throws SQLException {
String dbName = "mig_v76_" + DB_COUNTER.incrementAndGet();
try (Connection conn = DriverManager.getConnection(
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
Statement stmt = conn.createStatement()) {
stmt.execute("CREATE DATABASE " + dbName);
}
dbUrl = baseUrl(dbName);
}
@Test
void precheck_abortsWhenBirthYearAfterDeathYear() throws SQLException {
migrateTo("75");
seedPerson("Corrupt", 1950, 1940);
assertThatThrownBy(this::migrateToLatest)
.hasMessageContaining("V76 aborted")
.hasMessageContaining("birth_year > death_year");
}
@Test
void precheck_abortsWhenYearZeroPresent() throws SQLException {
migrateTo("75");
seedPerson("Zero", 0, null);
assertThatThrownBy(this::migrateToLatest)
.hasMessageContaining("V76 aborted")
.hasMessageContaining("birth_year=0 or death_year=0");
}
@Test
void backfill_birthOnly_becomesYearPrecisionDate_deathStaysUnknown() throws SQLException {
migrateTo("75");
seedPerson("BirthOnly", 1901, null);
migrateToLatest();
LifeDates row = lifeDates("BirthOnly");
assertThat(row.birthDate()).hasToString("1901-01-01");
assertThat(row.birthPrecision()).isEqualTo("YEAR");
assertThat(row.deathDate()).isNull();
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
}
@Test
void backfill_deathOnly_becomesYearPrecisionDate_birthStaysUnknown() throws SQLException {
migrateTo("75");
seedPerson("DeathOnly", null, 1944);
migrateToLatest();
LifeDates row = lifeDates("DeathOnly");
assertThat(row.birthDate()).isNull();
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
assertThat(row.deathDate()).hasToString("1944-01-01");
assertThat(row.deathPrecision()).isEqualTo("YEAR");
}
@Test
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
migrateTo("75");
seedPerson("NoDates", null, null);
migrateToLatest();
LifeDates row = lifeDates("NoDates");
assertThat(row.birthDate()).isNull();
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
assertThat(row.deathDate()).isNull();
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
}
@Test
void backfill_neverProducesBirthDateAfterDeathDate() throws SQLException {
migrateTo("75");
seedPerson("SameYear", 1901, 1901);
seedPerson("Ordered", 1899, 1972);
migrateToLatest();
assertThat(countWhere("birth_date IS NOT NULL AND death_date IS NOT NULL AND birth_date > death_date"))
.isZero();
}
@Test
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
migrateTo("75");
seedPerson("Schema", 1901, 1944);
migrateToLatest();
assertThat(columnExists("birth_year")).isFalse();
assertThat(columnExists("death_year")).isFalse();
assertThat(columnExists("birth_date")).isTrue();
assertThat(columnExists("death_date")).isTrue();
for (String constraint : new String[]{
"chk_person_birth_before_death",
"chk_person_birth_date_precision_coherence",
"chk_person_birth_date_precision_values",
"chk_person_death_date_precision_coherence",
"chk_person_death_date_precision_values"}) {
assertThat(constraintExists(constraint)).as(constraint).isTrue();
}
}
// --- helpers ---
private static String baseUrl(String dbName) {
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
}
private void migrateTo(String targetVersion) {
flywayBuilder().target(targetVersion).load().migrate();
}
private void migrateToLatest() {
flywayBuilder().load().migrate();
}
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
return Flyway.configure()
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", "test-only"));
}
private void seedPerson(String lastName, Integer birthYear, Integer deathYear) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO persons (id, last_name, person_type, family_member, provisional, birth_year, death_year) "
+ "VALUES (gen_random_uuid(), ?, 'PERSON', false, false, ?, ?)")) {
stmt.setString(1, lastName);
stmt.setObject(2, birthYear);
stmt.setObject(3, deathYear);
stmt.executeUpdate();
}
}
private record LifeDates(Object birthDate, String birthPrecision, Object deathDate, String deathPrecision) {}
private LifeDates lifeDates(String lastName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT birth_date, birth_date_precision, death_date, death_date_precision "
+ "FROM persons WHERE last_name = ?")) {
stmt.setString(1, lastName);
try (ResultSet rs = stmt.executeQuery()) {
assertThat(rs.next()).as("person %s exists", lastName).isTrue();
return new LifeDates(
rs.getObject("birth_date"),
rs.getString("birth_date_precision"),
rs.getObject("death_date"),
rs.getString("death_date_precision"));
}
}
}
private long countWhere(String condition) throws SQLException {
try (Connection conn = connect();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM persons WHERE " + condition)) {
rs.next();
return rs.getLong(1);
}
}
private boolean columnExists(String columnName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND table_name = 'persons' AND column_name = ?")) {
stmt.setString(1, columnName);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
}
}
private boolean constraintExists(String constraintName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
stmt.setString(1, constraintName);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
}
}
private Connection connect() throws SQLException {
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
}
}

View File

@@ -4,7 +4,6 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
@@ -23,7 +22,6 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@@ -217,14 +215,6 @@ class PersonControllerTest {
public String getAlias() { return null; }
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public java.time.LocalDate getBirthDate() { return null; }
public org.raddatz.familienarchiv.document.DatePrecision getBirthDatePrecision() {
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
}
public java.time.LocalDate getDeathDate() { return null; }
public org.raddatz.familienarchiv.document.DatePrecision getDeathDatePrecision() {
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
}
public String getNotes() { return null; }
public boolean isFamilyMember() { return false; }
public boolean isProvisional() { return false; }
@@ -582,53 +572,18 @@ class PersonControllerTest {
void createPerson_returns200_withAllSixFields() throws Exception {
UUID id = UUID.randomUUID();
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
.alias("Oma Maria")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.deathDate(LocalDate.of(1975, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
.notes("Some notes").build();
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\"," +
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"DAY\"," +
"\"deathDate\":\"1975-01-01\",\"deathDatePrecision\":\"YEAR\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
.andExpect(jsonPath("$.birthDate").value("1901-03-14"))
.andExpect(jsonPath("$.birthDatePrecision").value("DAY"));
}
// ─── #773: malformed date payloads return structured 400s, not Jackson traces ──
// Jackson rejects unknown enum values by default. Verified 2026-06-12: the only
// DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper
// (OCR HTTP client) — the Spring MVC mapper has no READ_UNKNOWN_ENUM_VALUES_* override.
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400WithStructuredErrorCode_whenPrecisionEnumInvalid() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"INVALID_VALUE\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400WithStructuredErrorCode_whenBirthDateNotADate() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
"\"birthDate\":\"not-a-date\",\"birthDatePrecision\":\"DAY\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
.andExpect(jsonPath("$.birthYear").value(1901));
}
// ─── Phase 1.2: @Size constraints ─────────────────────────────────────────

View File

@@ -5,9 +5,7 @@ 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.document.DatePrecision;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
@@ -99,120 +97,24 @@ class PersonImportUpsertTest {
assertThat(result.getNotes()).isEqualTo("Nichte von Herbert");
}
// ─── life dates (ADR-025 extension via preferHumanDate, #773) ─────────────
@Test
void upsertBySourceRef_preservesDayPrecisionDate_onReimportWithDifferentYear() {
// A human entered the exact birthday in-app; the spreadsheet only knows a year.
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1890, 3, 14)).birthDatePrecision(DatePrecision.DAY).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
void upsertBySourceRef_fillsBlankYears_butPreservesHumanEditedYears_onReimport() {
// Existing has a human-set birthYear and a blank deathYear.
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram")
.lastName("Cram").birthYear(1890).deathYear(null).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1888)
.birthYear(1888).deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 3, 14));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
}
@Test
void upsertBySourceRef_preservesMonthPrecisionDate_onReimport() {
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.deathDate(LocalDate.of(1944, 11, 1)).deathDatePrecision(DatePrecision.MONTH).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1945)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.MONTH);
}
@Test
void upsertBySourceRef_refreshesYearPrecisionDate_whenSpreadsheetYearChanges() {
// YEAR precision means "the importer's year" — a corrected spreadsheet year wins.
Person yearOnly = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1890, 1, 1)).birthDatePrecision(DatePrecision.YEAR).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(yearOnly));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1888)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1888, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void upsertBySourceRef_fillsEmptyDateAtYearPrecision_onReimport() {
Person noDates = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void upsertBySourceRef_keepsDatesEmpty_whenSpreadsheetHasNoYear() {
Person noDates = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_translatesYearToDate_onFirstImport() {
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1890).deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getBirthYear()).isEqualTo(1890); // human value kept
assertThat(result.getDeathYear()).isEqualTo(1965); // blank filled from canonical
}
@Test
@@ -297,70 +199,4 @@ class PersonImportUpsertTest {
assertThat(result.getGeneration()).isEqualTo(3);
}
// ─── conflicting canonical life dates degrade instead of hitting the DB CHECK ──
// (chk_person_birth_before_death would abort the whole batch — REQ-IMP-001)
@Test
void upsertBySourceRef_dropsBothDates_whenCanonicalBirthAfterDeath_newPerson() {
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1950).deathYear(1949)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_keepsHandEnteredBirth_andDropsConflictingCanonicalDeath() {
// A human entered an exact birthday; the spreadsheet's death year lies before it.
// The hand-entered side must survive, the conflicting canonical refresh is dropped.
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1950, 6, 1)).birthDatePrecision(DatePrecision.DAY).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1949)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1950, 6, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_keepsExistingYearDates_whenCanonicalRefreshConflicts() {
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1900, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1980, 1, 1)).deathDatePrecision(DatePrecision.YEAR).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1990).deathYear(1985)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1980, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
}

View File

@@ -18,9 +18,6 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -913,146 +910,4 @@ class PersonRepositoryTest {
.setParameter(1, blockId).getSingleResult();
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
}
// ─── #773: PersonSummaryDTO year projection from birth_date/death_date ──────
@Test
void findAllWithDocumentCount_derivesYearsFromDates_nullSafe() {
personRepository.save(Person.builder()
.firstName("Maria").lastName("Datiert")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.build());
personRepository.save(Person.builder()
.firstName("Nora").lastName("Undatiert")
.build());
entityManager.flush();
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
PersonSummaryDTO dated = all.stream()
.filter(p -> "Datiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(dated.getBirthYear()).isEqualTo(1901);
assertThat(dated.getDeathYear()).isNull();
PersonSummaryDTO undated = all.stream()
.filter(p -> "Undatiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(undated.getBirthYear()).isNull();
assertThat(undated.getDeathYear()).isNull();
}
@Test
void searchWithDocumentCount_groupByPath_derivesYearsFromDates() {
personRepository.save(Person.builder()
.firstName("Herbert").lastName("Gruppiert")
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Gruppiert");
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthYear()).isEqualTo(1899);
assertThat(found.get(0).getDeathYear()).isEqualTo(1972);
}
@Test
void findByFilter_derivesYearsFromDates() {
personRepository.save(Person.builder()
.firstName("Filtriert").lastName("Person")
.birthDate(LocalDate.of(1920, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.findByFilter(
null, null, null, null, false, "Filtriert", 10, 0);
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthYear()).isEqualTo(1920);
assertThat(found.get(0).getDeathYear()).isNull();
}
// ─── #773 follow-up: full date + precision exposed on the summary projection ──
// (the mention dropdown renders precise life dates from the list endpoint)
@Test
void findAllWithDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Maria").lastName("Praezise")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.deathDate(LocalDate.of(1972, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
.build());
personRepository.save(Person.builder()
.firstName("Nora").lastName("Datenlos")
.build());
entityManager.flush();
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
PersonSummaryDTO dated = all.stream()
.filter(p -> "Praezise".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(dated.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
assertThat(dated.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(dated.getDeathDate()).isEqualTo(LocalDate.of(1972, 1, 1));
assertThat(dated.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
PersonSummaryDTO undated = all.stream()
.filter(p -> "Datenlos".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(undated.getBirthDate()).isNull();
assertThat(undated.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(undated.getDeathDate()).isNull();
assertThat(undated.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void searchWithDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Herbert").lastName("Suchbar")
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Suchbar");
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthDate()).isEqualTo(LocalDate.of(1899, 1, 1));
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1972, 6, 12));
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
}
@Test
void findTopByDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Top").lastName("Dokumentiert")
.birthDate(LocalDate.of(1910, 5, 1)).birthDatePrecision(DatePrecision.MONTH)
.build());
entityManager.flush();
List<PersonSummaryDTO> top = personRepository.findTopByDocumentCount(10);
PersonSummaryDTO found = top.stream()
.filter(p -> "Dokumentiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(found.getBirthDate()).isEqualTo(LocalDate.of(1910, 5, 1));
assertThat(found.getBirthDatePrecision()).isEqualTo(DatePrecision.MONTH);
assertThat(found.getDeathDate()).isNull();
assertThat(found.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void findByFilter_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Gefiltert").lastName("Genau")
.deathDate(LocalDate.of(1944, 11, 2)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.findByFilter(
null, null, null, null, false, "Gefiltert", 10, 0);
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthDate()).isNull();
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 2));
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
}
}

View File

@@ -8,9 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
import org.raddatz.familienarchiv.person.PersonNameAliasType;
@@ -19,7 +17,6 @@ import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -244,49 +241,27 @@ class PersonServiceTest {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1975, 11, 2)); dto.setDeathDatePrecision(DatePrecision.DAY);
dto.setNotes("Some notes");
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes");
Person result = personService.createPerson(dto);
assertThat(result.getFirstName()).isEqualTo("Maria");
assertThat(result.getLastName()).isEqualTo("Raddatz");
assertThat(result.getAlias()).isEqualTo("Oma Maria");
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1975, 11, 2));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getBirthYear()).isEqualTo(1901);
assertThat(result.getDeathYear()).isEqualTo(1975);
assertThat(result.getNotes()).isEqualTo("Some notes");
}
@Test
void createPerson_dto_rejectsDateWithUnknownPrecision() {
void createPerson_dto_yearValidationFires_whenBirthYearNegative() {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test");
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.UNKNOWN);
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1);
assertThatThrownBy(() -> personService.createPerson(dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
}
@Test
void createPerson_dto_treatsNullPrecisionWithNullDateAsUnknown() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.PERSON);
Person result = personService.createPerson(dto);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
@@ -625,135 +600,114 @@ class PersonServiceTest {
assertThat(result.getNotes()).isNull();
}
// ─── updatePerson (birth/death dates) ────────────────────────────────────
// ─── updatePerson (birth/death years) ────────────────────────────────────
@Test
void updatePerson_persistsBirthAndDeathDateWithPrecision() {
void updatePerson_persistsBirthAndDeathYear() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setDeathDate(LocalDate.of(1965, 6, 12)); dto.setDeathDatePrecision(DatePrecision.DAY);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 6, 12));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isEqualTo(1965);
}
@Test
void updatePerson_throwsBirthAfterDeath_whenBirthDateAfterDeathDate() {
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1970, 5, 1)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1950, 5, 1)); dto.setDeathDatePrecision(DatePrecision.DAY);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBirthAfterDeath_onMixedPrecisionLateBirthday() {
// Known limitation (#773): DAY-precision birth late in the death's YEAR-precision year
// compares against the year's backfilled Jan 1st and is rejected. The error message
// carries the workaround hint via the BIRTH_AFTER_DEATH i18n key.
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1901, 11, 15)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1901, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
}
@Test
void updatePerson_doesNotThrow_whenBirthDateSetButDeathDateNull() {
void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() {
// Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isNull();
}
@Test
void updatePerson_allowsEqualBirthAndDeathDate() {
void updatePerson_allowsSameYear() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1900, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setDeathDate(LocalDate.of(1900, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getBirthYear()).isEqualTo(1900);
assertThat(result.getDeathYear()).isEqualTo(1900);
}
// ─── Date/precision coherence (V76 CHECK constraint mirror) ─────────────
// ─── Phase 1.3: Year range bounds (> 0) ──────────────────────────────────
@Test
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionUnknown() {
void updatePerson_throwsBadRequest_whenBirthYearIsZero() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setDeathDate(LocalDate.of(1944, 11, 2)); dto.setDeathDatePrecision(DatePrecision.UNKNOWN);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionNull() {
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1901, 3, 14));
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsInvalidDatePrecision_whenPrecisionSetWithoutDate() {
void updatePerson_throwsBadRequest_whenDeathYearIsZero() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
// ─── findCorrespondents ──────────────────────────────────────────────────
@@ -1105,25 +1059,4 @@ class PersonServiceTest {
assertThat(result.direct()).hasSize(1);
assertThat(result.partial()).isEmpty();
}
// --- getPersonsByGeneration ---
@Test
void getPersonsByGeneration_delegates_to_repository() {
Person p = Person.builder().id(UUID.randomUUID()).lastName("Müller").generation(2).build();
when(personRepository.findByGeneration(2)).thenReturn(List.of(p));
List<Person> result = personService.getPersonsByGeneration(2);
assertThat(result).containsExactly(p);
}
@Test
void getPersonsByGeneration_returns_emptyList_when_no_match() {
when(personRepository.findByGeneration(99)).thenReturn(List.of());
List<Person> result = personService.getPersonsByGeneration(99);
assertThat(result).isEmpty();
}
}

View File

@@ -100,13 +100,6 @@ class ArchitectureTest {
.and().resideInAPackage("..audit..")
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
@ArchTest
static final ArchRule services_only_access_own_domain_repositories_timeline =
noClasses()
.that().areAnnotatedWith(Service.class)
.and().resideInAPackage("..timeline..")
.should().dependOnClassesThat(foreignJpaRepositoryFor("timeline"));
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
// where it can be audited and reasoned about independently.
@@ -129,8 +122,7 @@ class ArchitectureTest {
.that().areAnnotatedWith(Entity.class)
.should().resideInAnyPackage(
"..document..", "..person..", "..tag..", "..user..",
"..geschichte..", "..notification..", "..ocr..", "..audit..",
"..timeline.."
"..geschichte..", "..notification..", "..ocr..", "..audit.."
);
// TODO Rule 5: Controllers expose endpoints under their domain prefix

View File

@@ -1,377 +0,0 @@
package org.raddatz.familienarchiv.timeline;
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.document.DatePrecision;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DerivedEventsAssemblyTest {
@Mock private TimelineEventRepository events;
@Mock private PersonService personService;
@Mock private DocumentService documentService;
@Mock private RelationshipService relationshipService;
@InjectMocks private TimelineEventService service;
// --- factory helpers ---
private Person makePerson(LocalDate birthDate, DatePrecision birthPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(true)
.birthDate(birthDate)
.birthDatePrecision(birthPrecision)
.build();
}
private Person makePersonWithDeath(LocalDate deathDate, DatePrecision deathPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Hans")
.lastName("Raddatz")
.familyMember(true)
.deathDate(deathDate)
.deathDatePrecision(deathPrecision)
.build();
}
private Person makePersonWithBoth(
LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(true)
.birthDate(birthDate)
.birthDatePrecision(birthPrecision)
.deathDate(deathDate)
.deathDatePrecision(deathPrecision)
.build();
}
private Person makeNonFamilyPerson(LocalDate birthDate, DatePrecision precision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(false)
.birthDate(birthDate)
.birthDatePrecision(precision)
.build();
}
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
return PersonRelationship.builder()
.id(UUID.randomUUID())
.person(a)
.relatedPerson(b)
.relationType(RelationType.SPOUSE_OF)
.fromYear(fromYear)
.build();
}
// --- REQ-001: birth events ---
@Test
void should_emit_one_geburt_for_person_with_birthdate() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO event = result.get(0);
assertThat(event.derived()).isTrue();
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH);
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12));
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
assertThat(event.title()).isEqualTo(anna.getDisplayName());
}
// --- REQ-003: null birthDate → no Geburt event ---
@Test
void should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long todCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.DEATH)
.count();
assertThat(todCount).isZero();
}
// --- REQ-004: null deathDate → no Tod event ---
@Test
void should_emit_no_events_for_person_with_neither_date() {
Person nobody = Person.builder()
.id(UUID.randomUUID())
.firstName("Hans")
.lastName("Raddatz")
.familyMember(true)
.build();
when(personService.findAllFamilyMembers()).thenReturn(List.of(nobody));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
// --- REQ-002: death events ---
@Test
void should_emit_one_tod_for_person_with_deathdate() {
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO event = result.get(0);
assertThat(event.derived()).isTrue();
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH);
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4));
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
assertThat(event.title()).isEqualTo(hans.getDisplayName());
}
// --- REQ-002 + REQ-003 combined ---
@Test
void should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only() {
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.YEAR);
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
assertThat(result.get(0).derivedType()).isEqualTo(DerivedEventType.DEATH);
}
// --- REQ-005: Heirat with fromYear ---
@Test
void should_emit_one_heirat_for_spouse_edge_with_fromYear() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
List<TimelineEntryDTO> heiraten = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.derived()).isTrue();
assertThat(heirat.type()).isEqualTo(EventType.PERSONAL);
assertThat(heirat.derivedType()).isEqualTo(DerivedEventType.MARRIAGE);
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1930, 1, 1));
assertThat(heirat.precision()).isEqualTo(DatePrecision.YEAR);
}
// --- REQ-006: Heirat with null fromYear → emitted with UNKNOWN precision ---
@Test
void should_emit_unknown_precision_heirat_when_fromYear_is_null() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, null);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
List<TimelineEntryDTO> heiraten = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.eventDate()).isNull();
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
}
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
@Test
void should_emit_exactly_one_heirat_when_both_spouses_in_scope() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
@Test
void should_emit_two_heirat_for_person_married_to_two_partners() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePerson(null, DatePrecision.UNKNOWN);
Person karl = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge1 = makeSpouseEdge(anna, hans, 1930);
PersonRelationship edge2 = makeSpouseEdge(anna, karl, 1945);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans, karl));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge1, edge2));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(2);
}
// --- REQ-001 precision pass-through ---
@Test
void should_pass_birth_precision_through_unchanged() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
assertThat(result.get(0).precision()).isEqualTo(DatePrecision.DAY);
}
// --- REQ-008: synthetic prefixed ids, never UUID ---
@Test
void should_mint_prefixed_synthetic_ids_never_uuid() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO entry = result.get(0);
assertThat(entry.derived()).isTrue();
assertThat(entry.eventId()).isNull();
assertThat(entry.documentId()).isNull();
}
// --- REQ-010: display names on Heirat ---
@Test
void should_emit_heirat_with_displayname_for_both_spouses() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> heiraten = service.assembleDerivedEvents().stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.title()).isNotNull().isNotBlank();
assertThat(heirat.linkedPersonIds()).hasSize(2);
}
// --- REQ-007 note: assumption/documentation test ---
@Test
void self_spouse_edge_invariant_is_enforced_by_db_constraint() {
// Assumption test — documents that the DB constraint prevents self-edges;
// the service does not guard this itself.
// The unique_spouse_pair index (V55) using LEAST/GREATEST is the authoritative guard.
// This test verifies that if an edge were somehow inserted (impossible in prod),
// the service would still produce one event (not zero or an exception).
Person anna = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship selfEdge = makeSpouseEdge(anna, anna, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(selfEdge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
// --- REQ-012: non-family-member persons excluded from Geburt/Tod ---
@Test
void should_exclude_non_family_member_persons_from_derived_events() {
Person nonMember = makeNonFamilyPerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of());
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
// --- REQ-013: Heirat emitted even when one spouse has familyMember=false ---
@Test
void should_emit_heirat_when_one_spouse_is_not_family_member() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person nonMember = makeNonFamilyPerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, nonMember, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
// --- REQ-014: empty family-member list → empty result, no error ---
@Test
void should_emit_zero_events_when_no_family_members() {
when(personService.findAllFamilyMembers()).thenReturn(List.of());
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
}

View File

@@ -1,139 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TimelineController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class TimelineControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean TimelineService timelineService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final TimelineDTO EMPTY = new TimelineDTO(List.of(), List.of());
@BeforeEach
void resolveDefaultPrincipal() {
when(userService.findByEmail("user"))
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
}
// ─── Security ─────────────────────────────────────────────────────────────
@Test
void returns_401_when_unauthenticated() throws Exception {
// REQ-014
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isUnauthorized());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "WRITE_ALL")
void returns_403_when_authenticated_without_read_all() throws Exception {
// REQ-015
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isForbidden());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_200_with_read_all_permission() throws Exception {
// REQ-001
when(timelineService.assemble(any())).thenReturn(EMPTY);
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.years").isArray())
.andExpect(jsonPath("$.undated").isArray());
}
// ─── Parameter binding ────────────────────────────────────────────────────
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void valid_params_are_forwarded_to_service() throws Exception {
UUID personId = UUID.randomUUID();
when(timelineService.assemble(any())).thenReturn(EMPTY);
mockMvc.perform(get("/api/timeline")
.param("personId", personId.toString())
.param("generation", "2")
.param("type", "HISTORICAL")
.param("fromYear", "1914")
.param("toYear", "1918"))
.andExpect(status().isOk());
verify(timelineService).assemble(new TimelineFilter(personId, 2, EventType.HISTORICAL, 1914, 1918));
}
// ─── Validation errors ────────────────────────────────────────────────────
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_on_bad_type_value() throws Exception {
// REQ-018 — Spring enum binding rejects unknown value
mockMvc.perform(get("/api/timeline").param("type", "NOT_A_TYPE"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_when_fromYear_greater_than_toYear() throws Exception {
// REQ-016 — service throws bad request, controller propagates it
when(timelineService.assemble(any()))
.thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"toYear must not be before fromYear"));
mockMvc.perform(get("/api/timeline")
.param("fromYear", "1920")
.param("toYear", "1914"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_when_generation_is_negative() throws Exception {
// REQ-017 — @Min(0) on generation parameter
mockMvc.perform(get("/api/timeline").param("generation", "-1"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_404_when_person_not_found() throws Exception {
// REQ-019
when(timelineService.assemble(any()))
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
mockMvc.perform(get("/api/timeline").param("personId", UUID.randomUUID().toString()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code", is("PERSON_NOT_FOUND")));
}
}

View File

@@ -1,95 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
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.PersonRepository;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
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.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves V77's FK {@code ON DELETE CASCADE} on the join tables: deleting a linked Person or
* Document drops the join row and leaves the {@link TimelineEvent} intact (a person/document
* delete must never 500 — V71-class regression guard). Needs the full Spring context for
* {@link PersonService}/{@link DocumentService}, mirroring {@code PersonServiceIntegrationTest}.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineEventCascadeIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineEventRepository events;
@Autowired PersonRepository personRepository;
@Autowired PersonService personService;
@Autowired DocumentRepository documentRepository;
@Autowired DocumentService documentService;
@Autowired JdbcTemplate jdbc;
@PersistenceContext EntityManager em;
private TimelineEvent.TimelineEventBuilder makeEvent() {
return TimelineEvent.builder()
.title("Hochzeit von Anna und Otto")
.type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 7, 28))
.createdBy(UUID.randomUUID())
.updatedBy(UUID.randomUUID());
}
@Test
void deleting_linked_person_keeps_event_and_drops_join_row() {
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
TimelineEvent event = events.save(makeEvent().persons(Set.of(anna)).build());
em.flush();
em.clear();
personService.deletePerson(anna.getId());
em.flush();
em.clear();
assertThat(events.findById(event.getId())).isPresent();
Integer joinRows = jdbc.queryForObject(
"SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?",
Integer.class, event.getId());
assertThat(joinRows).isZero();
}
@Test
void deleting_linked_document_keeps_event_and_drops_join_row() {
Document letter = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
TimelineEvent event = events.save(makeEvent().documents(Set.of(letter)).build());
em.flush();
em.clear();
documentService.deleteDocument(letter.getId(), UUID.randomUUID());
em.flush();
em.clear();
assertThat(events.findById(event.getId())).isPresent();
Integer joinRows = jdbc.queryForObject(
"SELECT COUNT(*) FROM timeline_event_documents WHERE timeline_event_id = ?",
Integer.class, event.getId());
assertThat(joinRows).isZero();
}
}

View File

@@ -1,273 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.mockito.ArgumentMatchers.any;
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.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;
@WebMvcTest(TimelineEventController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class TimelineEventControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean TimelineEventService timelineEventService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final String VALID_JSON =
"{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}";
/** Default principal resolution for @WithMockUser's "user"; capture tests override with a known id. */
@BeforeEach
void resolveDefaultPrincipal() {
when(userService.findByEmail("user"))
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
}
private TimelineEventView sampleView() {
return new TimelineEventView(
UUID.randomUUID(), "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 1, 1),
org.raddatz.familienarchiv.document.DatePrecision.YEAR, null, null, 0L,
UUID.randomUUID(), LocalDateTime.now(), UUID.randomUUID(), LocalDateTime.now(),
List.of(), List.of());
}
// ─── POST /api/timeline/events ───────────────────────────────────────────
@Test
void create_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void create_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns201_andViewBody_whenWriteAll() throws Exception {
when(timelineEventService.create(any(), any())).thenReturn(sampleView());
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("Hochzeit"))
.andExpect(jsonPath("$.version").value(0));
}
// ─── PUT /api/timeline/events/{id} ───────────────────────────────────────
@Test
void update_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void update_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void update_returns200_andViewBody_whenWriteAll() throws Exception {
when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Hochzeit"));
}
// ─── DELETE /api/timeline/events/{id} ────────────────────────────────────
@Test
void delete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void delete_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void delete_returns204_whenWriteAll() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isNoContent());
}
// ─── GET /api/timeline/events/{id} ───────────────────────────────────────
@Test
void getEvent_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getEvent_returns200_whenAuthenticated() throws Exception {
when(timelineEventService.getEvent(any())).thenReturn(sampleView());
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Hochzeit"));
}
@Test
@WithMockUser
void getEvent_returns404_whenMissing() throws Exception {
when(timelineEventService.getEvent(any()))
.thenThrow(DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, "not found"));
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isNotFound());
}
// ─── service-thrown link errors map to status ────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns404_whenServiceThrowsPersonNotFound() throws Exception {
when(timelineEventService.create(any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found"));
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":[\""
+ UUID.randomUUID() + "\"]}"))
.andExpect(status().isNotFound());
}
// ─── Bean Validation 400s carry code VALIDATION_ERROR (R1) ───────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTitleBlank() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\" \",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTypeNull() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"eventDate\":\"1914-07-28\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenDescriptionTooLong() throws Exception {
String description = "x".repeat(5001);
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"description\":\""
+ description + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTooManyPersonIds() throws Exception {
String ids = Stream.generate(() -> "\"" + UUID.randomUUID() + "\"").limit(51).collect(Collectors.joining(","));
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":["
+ ids + "]}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
// ─── actorId is the resolved session principal, not a body field (both write paths) ──
@Test
@WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
void create_passesResolvedPrincipalIdAsActor_ignoringBodyCreatedBy() throws Exception {
UUID principalId = UUID.randomUUID();
when(userService.findByEmail("curator@example.com"))
.thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
when(timelineEventService.create(any(), any())).thenReturn(sampleView());
// body carries a forged createdBy — it must be ignored, actor comes from the principal
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"createdBy\":\""
+ UUID.randomUUID() + "\"}"))
.andExpect(status().isCreated());
verify(timelineEventService).create(any(), eq(principalId));
}
@Test
@WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
void update_passesResolvedPrincipalIdAsActor_ignoringBodyUpdatedBy() throws Exception {
UUID principalId = UUID.randomUUID();
UUID eventId = UUID.randomUUID();
when(userService.findByEmail("curator@example.com"))
.thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
mockMvc.perform(put("/api/timeline/events/" + eventId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"updatedBy\":\""
+ UUID.randomUUID() + "\"}"))
.andExpect(status().isOk());
verify(timelineEventService).update(eq(eventId), any(), eq(principalId));
}
}

View File

@@ -1,109 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
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.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Service-level integration scope the entity/DB tests ({@link TimelineEventTest}) don't reach: the
* in-transaction view assembly survives a context clear (the {@code @Transactional(readOnly = true)}
* LazyInit guard), the serialized view leaks no curator-internal fields, and the {@code @Version}
* optimistic lock engages end-to-end (the Mockito test only proves the catch branch). Real Postgres
* (V77 CHECK constraints are Postgres-specific) via {@link PostgresContainerConfig}.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineEventServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineEventService service;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@PersistenceContext EntityManager em;
// Built locally — the webEnvironment=NONE context has no auto-configured ObjectMapper bean.
// findAndRegisterModules() pulls in JavaTimeModule so the view's LocalDate/LocalDateTime serialize.
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private TimelineEventRequest request(String title, Long version, List<UUID> personIds, List<UUID> documentIds) {
return new TimelineEventRequest(title, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, version, personIds, documentIds);
}
@Test
void getEvent_after_context_clear_populates_links_and_leaks_no_internal_fields() throws Exception {
Person anna = personRepository.save(Person.builder()
.firstName("Anna").lastName("Müller").notes("GEHEIM-NOTIZ").provisional(true).build());
Document letter = documentRepository.save(Document.builder()
.title("Brief an Anna").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
UUID eventId = service.create(
request("Hochzeit", null, List.of(anna.getId()), List.of(letter.getId())), UUID.randomUUID()).id();
em.flush();
em.clear();
// Fresh read — NOT the create return value (that view was assembled while the entity was
// managed). Only a separate read after the clear proves the readOnly LazyInit guard.
TimelineEventView fresh = service.getEvent(eventId);
assertThat(fresh.persons()).singleElement().satisfies(p -> {
assertThat(p.firstName()).isEqualTo("Anna");
assertThat(p.lastName()).isEqualTo("Müller");
});
assertThat(fresh.documents()).singleElement().satisfies(d ->
assertThat(d.title()).isEqualTo("Brief an Anna"));
// Assert on the SERIALIZED JSON: a getter re-introducing a leaked field later would slip
// past a field-level check.
String json = objectMapper.writeValueAsString(fresh);
assertThat(json)
.doesNotContain("GEHEIM-NOTIZ")
.doesNotContain("notes")
.doesNotContain("provisional")
.doesNotContain("password");
}
@Test
void concurrent_update_with_stale_version_yields_TIMELINE_EVENT_CONFLICT() {
UUID editorA = UUID.randomUUID();
UUID editorB = UUID.randomUUID();
UUID eventId = service.create(request("Original", null, null, null), editorA).id();
em.flush();
em.clear();
// Editor A saves with the version they last saw (0) → succeeds, version advances to 1.
service.update(eventId, request("Edit A", 0L, null, null), editorA);
em.flush();
em.clear();
// Editor B still holds the stale version 0 → the versioned UPDATE matches no row → 409.
assertThatThrownBy(() -> service.update(eventId, request("Edit B", 0L, null, null), editorB))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
}
}

View File

@@ -1,453 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.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.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.time.LocalDate;
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.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TimelineEventServiceTest {
@Mock TimelineEventRepository events;
@Mock PersonService personService;
@Mock DocumentService documentService;
@InjectMocks TimelineEventService service;
private final UUID actor = UUID.randomUUID();
private final UUID secondEditor = UUID.randomUUID();
/** Mirrors #774's makeEvent defaults so NOT NULL createdBy/updatedBy aren't tripped for the wrong reason. */
private TimelineEventRequest baseRequest() {
return new TimelineEventRequest(
"Hochzeit von Anna und Otto", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, null, null);
}
private Person makePerson(UUID id) {
return Person.builder().id(id).firstName("Anna").lastName("Müller").build();
}
private Document makeDocument(UUID id) {
return Document.builder().id(id).title("Brief an Anna").documentDate(LocalDate.of(1914, 6, 1)).build();
}
/** A managed, persisted event for update/delete/get paths — version 5, distinct creator. */
private TimelineEvent existingEvent(UUID id, UUID creator) {
return TimelineEvent.builder()
.id(id).title("Original").type(EventType.PERSONAL).eventDate(LocalDate.of(1914, 1, 1))
.precision(DatePrecision.YEAR).createdBy(creator).updatedBy(creator).version(5L)
.build();
}
/** Stubs saveAndFlush to mimic Hibernate setting version=0 on insert; returns the same managed entity. */
private void stubFlushSetsVersion() {
when(events.saveAndFlush(any())).thenAnswer(inv -> {
TimelineEvent e = inv.getArgument(0);
if (e.getVersion() == null) e.setVersion(0L);
return e;
});
}
private TimelineEvent captureSaved() {
ArgumentCaptor<TimelineEvent> captor = ArgumentCaptor.forClass(TimelineEvent.class);
verify(events).saveAndFlush(captor.capture());
return captor.getValue();
}
// --- create ---
@Test
void create_persists_and_sets_createdBy_and_updatedBy_from_actorId() {
stubFlushSetsVersion();
TimelineEventView view = service.create(baseRequest(), actor);
TimelineEvent persisted = captureSaved();
assertThat(persisted.getCreatedBy()).isEqualTo(actor);
assertThat(persisted.getUpdatedBy()).isEqualTo(actor);
assertThat(view.title()).isEqualTo("Hochzeit von Anna und Otto");
assertThat(view.version()).isEqualTo(0L);
}
@Test
void create_defaults_precision_to_YEAR_when_omitted() {
stubFlushSetsVersion();
TimelineEventView view = service.create(baseRequest(), actor);
assertThat(view.precision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void create_normalizes_eventDate_to_first_of_january_when_precision_is_YEAR() {
stubFlushSetsVersion();
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
DatePrecision.YEAR, null, null, null, null, null);
TimelineEventView view = service.create(request, actor);
assertThat(view.eventDate()).isEqualTo(LocalDate.of(1914, 1, 1));
}
@Test
void create_with_null_link_lists_yields_empty_collections_no_npe() {
stubFlushSetsVersion();
TimelineEventView view = service.create(baseRequest(), actor);
assertThat(view.persons()).isEmpty();
assertThat(view.documents()).isEmpty();
}
// --- RANGE invariant (both directions are separate tests; each asserts saveAndFlush never called) ---
@Test
void create_rejects_eventDateEnd_when_precision_is_not_RANGE() {
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
DatePrecision.YEAR, LocalDate.of(1918, 1, 1), null, null, null, null);
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_rejects_RANGE_with_null_eventDateEnd() {
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
DatePrecision.RANGE, null, null, null, null, null);
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_rejects_RANGE_with_eventDateEnd_before_eventDate() {
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_accepts_RANGE_with_eventDateEnd_equal_to_eventDate() {
stubFlushSetsVersion();
LocalDate sameDay = LocalDate.of(1914, 7, 28);
TimelineEventRequest request = new TimelineEventRequest(
"Eintägiges Ereignis", EventType.HISTORICAL, sameDay,
DatePrecision.RANGE, sameDay, null, null, null, null); // end == start is a valid closed range
TimelineEventView view = service.create(request, actor);
assertThat(view.eventDate()).isEqualTo(sameDay);
assertThat(view.eventDateEnd()).isEqualTo(sameDay);
}
// --- title-length service guard ---
@Test
void create_rejects_title_longer_than_255_with_TIMELINE_TITLE_TOO_LONG() {
String overLong = "x".repeat(256);
TimelineEventRequest request = new TimelineEventRequest(
overLong, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, null, null);
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_TITLE_TOO_LONG);
verify(events, never()).saveAndFlush(any());
}
// --- link resolution (fail-closed, dedupe-first) ---
@Test
void create_resolves_persons_and_documents() {
stubFlushSetsVersion();
UUID personId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(personId), List.of(docId));
TimelineEventView view = service.create(request, actor);
assertThat(view.persons()).singleElement().satisfies(p -> assertThat(p.id()).isEqualTo(personId));
assertThat(view.documents()).singleElement().satisfies(d -> assertThat(d.id()).isEqualTo(docId));
}
@Test
void create_with_duplicate_personIds_resolves_to_single_link_not_404() {
stubFlushSetsVersion();
UUID personId = UUID.randomUUID();
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(personId, personId), null);
TimelineEventView view = service.create(request, actor);
assertThat(view.persons()).hasSize(1);
}
@Test
void create_with_duplicate_documentIds_resolves_to_single_link_not_404() {
stubFlushSetsVersion();
UUID docId = UUID.randomUUID();
when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, null, List.of(docId, docId));
TimelineEventView view = service.create(request, actor);
assertThat(view.documents()).hasSize(1);
}
@Test
void create_rejects_unknown_personId_with_PERSON_NOT_FOUND_without_saving() {
UUID known = UUID.randomUUID();
UUID unknown = UUID.randomUUID();
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(known))); // one missing
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(known, unknown), null);
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.PERSON_NOT_FOUND);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_rejects_unknown_documentId_with_DOCUMENT_NOT_FOUND_without_saving() {
UUID unknown = UUID.randomUUID();
when(documentService.getDocumentById(unknown))
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + unknown));
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, null, List.of(unknown));
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND);
verify(events, never()).saveAndFlush(any());
}
// --- update ---
@Test
void update_replaces_links_preserves_createdBy_and_advances_updatedBy_to_second_editor() {
UUID id = UUID.randomUUID();
UUID creator = UUID.randomUUID();
UUID newPersonId = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, creator);
existing.getPersons().add(makePerson(UUID.randomUUID())); // pre-existing link to be replaced
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(newPersonId)));
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(newPersonId), null);
service.update(id, request, secondEditor);
assertThat(existing.getCreatedBy()).isEqualTo(creator);
assertThat(existing.getUpdatedBy()).isEqualTo(secondEditor);
assertThat(existing.getPersons()).singleElement()
.satisfies(p -> assertThat(p.getId()).isEqualTo(newPersonId));
}
@Test
void update_with_empty_link_lists_clears_all_links() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
existing.getPersons().add(makePerson(UUID.randomUUID()));
existing.getDocuments().add(makeDocument(UUID.randomUUID()));
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(), List.of());
service.update(id, request, secondEditor);
assertThat(existing.getPersons()).isEmpty();
assertThat(existing.getDocuments()).isEmpty();
}
@Test
void update_rejects_RANGE_with_eventDateEnd_before_eventDate_without_saving() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
when(events.findById(id)).thenReturn(Optional.of(existing));
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
assertThatThrownBy(() -> service.update(id, request, secondEditor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void update_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
UUID id = UUID.randomUUID();
when(events.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.update(id, baseRequest(), actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
}
// --- version / optimistic lock ---
// Note: the lock control is an explicit base-version compare (requireVersionMatch), NOT
// event.setVersion(clientVersion) — Hibernate silently ignores a manually-set @Version on a
// managed entity (proven by TimelineEventServiceIntegrationTest). The saveAndFlush+catch below
// is retained as the native backstop for two transactions flushing concurrently.
@Test
void update_with_null_version_skips_the_check_and_saves_last_write_wins() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
service.update(id, baseRequest(), secondEditor); // baseRequest has null version
verify(events).saveAndFlush(existing); // no conflict despite an unknown client base
}
@Test
void update_with_in_sync_version_succeeds_and_saves() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, 5L, null, null); // matches the loaded version
TimelineEventView view = service.update(id, request, secondEditor);
assertThat(view).isNotNull();
verify(events).saveAndFlush(existing);
}
@Test
void update_with_stale_version_throws_conflict_without_saving() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
when(events.findById(id)).thenReturn(Optional.of(existing));
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, 2L, null, null); // stale: 2 != 5
assertThatThrownBy(() -> service.update(id, request, secondEditor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
verify(events, never()).saveAndFlush(any());
}
@Test
void update_translates_concurrent_flush_lock_failure_to_TIMELINE_EVENT_CONFLICT() {
// Native @Version backstop: an in-sync token passes the explicit guard, but a genuinely
// concurrent flush makes saveAndFlush throw — it must still surface as a 409, not a 500.
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any()))
.thenThrow(new ObjectOptimisticLockingFailureException(TimelineEvent.class, id));
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, 5L, null, null); // in-sync, passes the guard
assertThatThrownBy(() -> service.update(id, request, secondEditor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
}
// --- delete / getEvent ---
@Test
void delete_removes_existing_event() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
when(events.findById(id)).thenReturn(Optional.of(existing));
service.delete(id);
verify(events).delete(existing);
}
@Test
void delete_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
UUID id = UUID.randomUUID();
when(events.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.delete(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
}
@Test
void getEvent_returns_view_for_existing_event() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
when(events.findById(id)).thenReturn(Optional.of(existing));
TimelineEventView view = service.getEvent(id);
assertThat(view.id()).isEqualTo(id);
assertThat(view.title()).isEqualTo("Original");
}
@Test
void getEvent_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
UUID id = UUID.randomUUID();
when(events.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getEvent(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
}
}

View File

@@ -1,202 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Persistence + DB-constraint tests for {@link TimelineEvent} against real Postgres (V77).
* Mirrors {@code MigrationIntegrationTest}'s slice setup; never H2 — only the real DB proves
* enum-as-varchar storage, the RANGE/UNKNOWN CHECK constraints, FK cascade, and {@code @Version}.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TimelineEventTest {
@Autowired TimelineEventRepository events;
@Autowired PersonRepository persons;
@Autowired DocumentRepository documents;
@Autowired EntityManager em;
/**
* Sensible defaults; each test overrides only what it asserts. {@code createdBy}/{@code updatedBy}
* default to random UUIDs — both columns are NOT NULL and not auto-populated, so without these
* every test would fail at flush with the same constraint violation (red for the wrong reason).
* Precision is intentionally left unset so {@code @Builder.Default YEAR} can be exercised.
*/
private TimelineEvent.TimelineEventBuilder makeEvent() {
return TimelineEvent.builder()
.title("Hochzeit von Anna und Otto")
.type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 7, 28))
.createdBy(UUID.randomUUID())
.updatedBy(UUID.randomUUID());
}
@Test
void persists_and_loads_event_with_required_fields() {
TimelineEvent saved = events.save(makeEvent().build());
assertThat(saved.getId()).isNotNull();
}
@Test
void precision_defaults_to_YEAR_when_not_set() {
TimelineEvent saved = events.save(makeEvent().build());
assertThat(saved.getPrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void persists_event_with_linked_persons() {
Person anna = persons.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
TimelineEvent saved = events.save(makeEvent().persons(Set.of(anna)).build());
em.flush();
em.clear();
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getPersons()).extracting(Person::getId).containsExactly(anna.getId());
}
@Test
void persists_event_with_linked_documents() {
Document letter = documents.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
TimelineEvent saved = events.save(makeEvent().documents(Set.of(letter)).build());
em.flush();
em.clear();
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getDocuments()).extracting(Document::getId).containsExactly(letter.getId());
}
@Test
void eventDateEnd_round_trips_null_for_non_range() {
TimelineEvent saved = events.save(makeEvent().build()); // YEAR precision, no end
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd()).isNull();
}
@Test
void eventDateEnd_round_trips_value_for_range() {
TimelineEvent saved = events.save(makeEvent()
.precision(DatePrecision.RANGE)
.eventDate(LocalDate.of(1914, 1, 1))
.eventDateEnd(LocalDate.of(1918, 12, 31))
.build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd())
.isEqualTo(LocalDate.of(1918, 12, 31));
}
@Test
void description_round_trips_null() {
TimelineEvent saved = events.save(makeEvent().build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isNull();
}
@Test
void description_round_trips_multi_kb_text() {
// Proves TEXT has no length cap — @Column(columnDefinition = "TEXT") overrides
// Hibernate's default VARCHAR(255).
String longText = "Sommertage am See. ".repeat(500); // ~9.5 KB
TimelineEvent saved = events.save(makeEvent().description(longText).build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isEqualTo(longText);
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void range_invariant_rejects_non_null_end_without_range_precision() {
// precision YEAR + non-null end violates chk_timeline_event_range.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.eventDateEnd(LocalDate.of(1918, 12, 31))
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll(); // NOT_SUPPORTED opts out of the rollback; clean any leaked row
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void range_invariant_rejects_range_precision_without_end_date() {
// precision RANGE + null end violates chk_timeline_event_range.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.precision(DatePrecision.RANGE)
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll();
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void unknown_precision_is_rejected() {
// chk_timeline_event_precision forbids UNKNOWN — curated events are never undated.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.precision(DatePrecision.UNKNOWN)
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll();
}
}
@Test
void version_is_null_before_persist_and_zero_after_save() {
TimelineEvent fresh = makeEvent().build();
assertThat(fresh.getVersion()).isNull(); // @Version Long is null pre-persist
TimelineEvent saved = events.saveAndFlush(fresh);
assertThat(saved.getVersion()).isEqualTo(0L); // Hibernate sets 0 on insert
}
@ParameterizedTest
@EnumSource(value = DatePrecision.class, names = {"DAY", "MONTH", "SEASON", "YEAR", "APPROX"})
void all_non_unknown_precisions_are_accepted(DatePrecision precision) {
// Accept-side of chk_timeline_event_precision: every non-RANGE, non-UNKNOWN value persists.
// Documents that SEASON ("Sommer 1914") and APPROX ("ca. 1914") are intentionally legal,
// so an over-tight CHECK cannot ship green. (RANGE is covered by the round-trip test.)
TimelineEvent saved = events.saveAndFlush(makeEvent().precision(precision).build());
assertThat(saved.getId()).isNotNull();
}
}

View File

@@ -1,105 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link TimelineService} and {@link PersonRepository#findByGeneration}
* against real Postgres. Verifies that assembled output reflects persisted curated events and
* that the generation query handles null-generation rows correctly.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineService timelineService;
@Autowired TimelineEventRepository timelineEventRepository;
@Autowired PersonRepository personRepository;
@PersistenceContext EntityManager em;
// ─── PersonRepository.findByGeneration ────────────────────────────────────
@Test
void findByGeneration_returns_matching_persons() {
personRepository.save(Person.builder().lastName("Gen2A").generation(2).build());
personRepository.save(Person.builder().lastName("Gen2B").generation(2).build());
personRepository.save(Person.builder().lastName("Gen3").generation(3).build());
em.flush();
List<Person> result = personRepository.findByGeneration(2);
assertThat(result).extracting(Person::getLastName)
.containsExactlyInAnyOrder("Gen2A", "Gen2B");
}
@Test
void findByGeneration_returns_empty_list_not_npe_when_no_match() {
personRepository.save(Person.builder().lastName("Gen1").generation(1).build());
em.flush();
List<Person> result = personRepository.findByGeneration(99);
assertThat(result).isNotNull().isEmpty();
}
@Test
void findByGeneration_does_not_return_null_generation_persons() {
personRepository.save(Person.builder().lastName("NullGen").build()); // generation stays null
em.flush();
List<Person> result = personRepository.findByGeneration(1);
assertThat(result).extracting(Person::getLastName).doesNotContain("NullGen");
}
// ─── TimelineService.assemble end-to-end ─────────────────────────────────
@Test
void assemble_includes_persisted_curated_event_in_correct_year_band() {
UUID actorId = UUID.randomUUID();
TimelineEvent event = timelineEventRepository.save(TimelineEvent.builder()
.title("Sarajevo")
.type(EventType.HISTORICAL)
.eventDate(LocalDate.of(1914, 6, 28))
.precision(DatePrecision.DAY)
.createdBy(actorId)
.updatedBy(actorId)
.build());
em.flush();
em.clear();
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, null, null));
assertThat(result.years()).anySatisfy(y -> {
assertThat(y.year()).isEqualTo(1914);
assertThat(y.entries()).anySatisfy(e -> {
assertThat(e.title()).isEqualTo("Sarajevo");
assertThat(e.kind()).isEqualTo(Kind.EVENT);
assertThat(e.eventId()).isEqualTo(event.getId());
});
});
}
}

View File

@@ -1,72 +0,0 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
/**
* Verifies that {@link TimelineService#assemble} does not throw
* {@link org.hibernate.LazyInitializationException} when events have linked persons.
*
* <p>No class-level {@code @Transactional} — each test method runs without an outer
* transaction, matching production behaviour (controller has no {@code @Transactional}).
* If {@code assemble()} lacks {@code @Transactional(readOnly=true)}, accessing
* {@code ev.getPersons()} on detached entities throws LazyInitializationException.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class TimelineServiceLazyLoadTest {
@MockitoBean
S3Client s3Client;
@Autowired
TransactionTemplate transactionTemplate;
@Autowired
TimelineService timelineService;
@Autowired
TimelineEventRepository timelineEventRepository;
@Autowired
PersonRepository personRepository;
@Test
void assemble_does_not_throw_when_event_has_linked_persons() {
UUID actorId = UUID.randomUUID();
// Commit outside any test-managed transaction so entities are detached on return
transactionTemplate.execute(status -> {
Person person = personRepository.save(Person.builder().lastName("Müller").build());
timelineEventRepository.save(TimelineEvent.builder()
.title("Linked event")
.type(EventType.HISTORICAL)
.eventDate(LocalDate.of(1914, 7, 28))
.precision(DatePrecision.DAY)
.createdBy(actorId)
.updatedBy(actorId)
.persons(new HashSet<>(Set.of(person)))
.build());
return null;
});
assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null)));
}
}

View File

@@ -1,452 +0,0 @@
package org.raddatz.familienarchiv.timeline;
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.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TimelineServiceTest {
@Mock TimelineEventRepository eventRepository;
@Mock TimelineEventService timelineEventService;
@Mock DocumentService documentService;
@Mock PersonService personService;
@InjectMocks TimelineService timelineService;
// ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ────────────────────────
@Test
void within_band_order_day_precision_sorts_before_year() {
var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
var sorted = List.of(yearEntry, dayEntry).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted).containsExactly(dayEntry, yearEntry);
}
@Test
void within_band_order_same_precision_and_date_sorts_alphabetically() {
var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
var sorted = List.of(entryZ, entryA).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted).containsExactly(entryA, entryZ);
}
@Test
void within_band_order_same_title_uses_document_id_as_tiebreak() {
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null);
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null);
var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted.get(0).documentId()).isEqualTo(id1);
}
// ─── Assembly tests (issue-spec order) ──────────────────────────────────
@Test
void test1_empty_archive_returns_empty_dto() {
// REQ-013, REQ-007
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test2_one_year_letter_returns_one_year_band() {
// REQ-007
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER);
assertThat(result.undated()).isEmpty();
}
@Test
void test3a_null_date_letter_goes_to_undated() {
// REQ-003
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).hasSize(1);
}
@Test
void test3b_unknown_precision_letter_goes_to_undated() {
// REQ-003
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).hasSize(1);
}
@Test
void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() {
// REQ-005
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR)
.documentDate(LocalDate.of(1914, 1, 1))
.build(); // no sender, no senderText, no receivers, no receiverText
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
var entry = result.years().get(0).entries().get(0);
assertThat(entry.senderName()).isEqualTo("");
assertThat(entry.receiverName()).isEqualTo("");
}
@Test
void test5_day_precision_sorts_before_year_in_same_year_band() {
// REQ-002
var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter));
TimelineDTO result = timelineService.assemble(noFilters());
var entries = result.years().get(0).entries();
assertThat(entries).hasSize(2);
assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY);
assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void test6_same_precision_same_date_sorted_alphabetically_by_title() {
// REQ-002
var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA));
TimelineDTO result = timelineService.assemble(noFilters());
var entries = result.years().get(0).entries();
assertThat(entries).hasSize(2);
assertThat(entries.get(0).title()).isEqualTo("Adler");
assertThat(entries.get(1).title()).isEqualTo("Zimmer");
}
@Test
void test7a_range_event_placed_only_in_start_year_band() {
// REQ-004
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue();
}
@Test
void test7b_range_event_with_null_eventDateEnd_does_not_crash() {
// REQ-004
var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL,
LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null);
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException();
}
@Test
void test8_range_event_excluded_when_start_year_before_fromYear() {
// REQ-004
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
// fromYear=1915 → start year 1914 is outside → excluded
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null));
assertThat(result.years()).isEmpty();
}
@Test
void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() {
// REQ-009
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief");
var historicalEvent = event("Sarajevo", EventType.HISTORICAL,
LocalDate.of(1914, 6, 28), DatePrecision.DAY, null);
var personalEvent = event("Geburt", EventType.PERSONAL,
LocalDate.of(1914, 8, 1), DatePrecision.DAY, null);
when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
// filter: only HISTORICAL events
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null));
long letters = result.years().stream().flatMap(y -> y.entries().stream())
.filter(e -> e.kind() == Kind.LETTER).count();
long personalEvents = result.years().stream().flatMap(y -> y.entries().stream())
.filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count();
assertThat(letters).isEqualTo(1);
assertThat(personalEvents).isEqualTo(0);
}
@Test
void test9b_generation_filter_includes_letter_when_sender_matches_generation() {
// REQ-010
var sender = Person.builder().id(UUID.randomUUID())
.lastName("Mustermann").firstName("Max").generation(2).build();
var included = Document.builder().id(UUID.randomUUID()).title("Treffer")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(sender).build();
var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded));
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer");
}
@Test
void test9c_fromYear_toYear_inclusive_single_year_window() {
// REQ-011
var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher");
var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr");
var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr");
}
@Test
void test10_adversarial_and_logic_neither_event_passes_both_filters() {
// REQ-012 — type AND year must both pass
var wrongType = event("Personal", EventType.PERSONAL,
LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null);
var wrongYear = event("Historical outside", EventType.HISTORICAL,
LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null);
when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() {
// REQ-008
UUID personId = UUID.randomUUID();
var person = Person.builder().id(personId).lastName("Mustermann").build();
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(person)
.receivers(Set.of(person))
.build();
when(personService.getById(personId)).thenReturn(person);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null));
long total = result.years().stream().mapToLong(y -> y.entries().size()).sum()
+ result.undated().size();
assertThat(total).isEqualTo(1);
}
@Test
void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() {
// REQ-012
UUID personId = UUID.randomUUID();
var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build();
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(person).build();
var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build();
when(personService.getById(personId)).thenReturn(person);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of());
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test13_null_generation_sender_not_returned_by_generation_filter() {
// REQ-020 — both sender and receiver have null generation → excluded
var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(nullGenSender).build();
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test14_year_band_contains_only_event_when_no_letters_in_that_year() {
var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null);
when(eventRepository.findAll()).thenReturn(List.of(ev));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT);
}
@Test
void test15_range_event_start_year_equal_to_fromYear_is_included() {
// REQ-004 — inclusive lower bound
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
}
@Test
void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() {
// REQ-011
var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt");
var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst");
var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null));
assertThat(result.years()).hasSize(2);
assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue();
}
@Test
void fromYear_greater_than_toYear_throws_bad_request() {
// REQ-016 (service-layer guard)
assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914)))
.isInstanceOf(DomainException.class);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private static TimelineFilter noFilters() {
return new TimelineFilter(null, null, null, null, null);
}
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
date, null, title, null, null, UUID.randomUUID(), List.of(), null);
}
private static Document docWithDate(LocalDate date, DatePrecision precision) {
return Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(precision).documentDate(date).build();
}
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
return Document.builder().id(UUID.randomUUID()).title(title)
.metaDatePrecision(precision).documentDate(date).build();
}
private static TimelineEvent event(String title, EventType type, LocalDate date,
DatePrecision precision, LocalDate endDate) {
return TimelineEvent.builder().id(UUID.randomUUID())
.title(title).type(type)
.eventDate(date).precision(precision).eventDateEnd(endDate)
.build();
}
}

View File

@@ -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). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. Timeline domain codes: `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG`, plus a generic `CONFLICT` (409 optimistic-lock backstop). |
| `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`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
| `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

@@ -124,8 +124,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `POSTGRES_PASSWORD` | DB password | `change-me` | YES | YES |
| `POSTGRES_DB` | Database name | `family_archive_db` | YES | — |
> **PgBouncer pooling mode:** The `journey_items.position_seq` dedup constraint uses `DEFERRABLE INITIALLY DEFERRED`. This requires PgBouncer in **transaction-mode** (not statement-mode) pooling. Do not switch to statement-level pooling — deferred constraints only work within a single transaction session.
### MinIO container
| Variable | Purpose | Default | Required? | Sensitive? |
@@ -518,26 +516,6 @@ docker exec -i archive-db psql -U ${POSTGRES_USER} ${POSTGRES_DB} < backup-YYYYM
Automated backup (nightly `pg_dump` + MinIO `mc mirror` over Tailscale to `heim-nas`) is a follow-up issue. Until that ships: **manual backups are the only recovery option.**
### Deploy note — V76 (persons birth/death → date + precision, #773)
V76 drops `persons.birth_year`/`death_year` after backfilling the new
`birth_date`/`death_date` + precision columns — a **one-way migration** (Flyway cannot
roll it back). Before deploying:
1. Take a manual `pg_dump` (see above) — there is no automated nightly backup yet, so
confirm the dump completed before starting the deploy.
2. No maintenance window is required: the pre-check + DDL run in one atomic Flyway
transaction, and this single-writer archive has no concurrent importers during deploy.
If post-deploy data issues are found, restore **only the persons table** from the
pre-migration dump (targeted restore, not a full-database restore):
```bash
pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump
```
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
### Rollback
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:

View File

@@ -164,23 +164,6 @@ _Not to be confused with a document item's optional note_ — a document item's
**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.
**TimelineEvent** (`TimelineEvent`, table `timeline_events`) `[internal]` — a curated event on the family timeline (*Zeitstrahl*), authored by a curator rather than OCR-derived. Carries the same date block as a `Document` (`eventDate` + `precision` + nullable `eventDateEnd`) so events and letters render through one path, plus a `title`, optional `description`, an `EventType`, and `ManyToMany` links to the `Person`s it involves and the `Document`s that support it (both join FKs `ON DELETE CASCADE`). Diverges from `Document` with an optimistic-lock `@Version` and a NOT NULL `createdBy`/`updatedBy` audit trail (bare UUIDs, no FK to `app_users`) for the multi-curator edit flow. Two DB CHECKs: `event_date_end` is non-null **iff** precision is `RANGE` (a strict biconditional, intentionally tighter than `Document`'s open-ended ranges), and `precision` is never `UNKNOWN` (a curated event always has at least a year; `SEASON`/`APPROX` stay legal). See ADR-040.
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain.
**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view).
**derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043).
_Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database.
**DerivedEventType** (`DerivedEventType`) `[internal]` — enum with three values: `BIRTH`, `DEATH`, `MARRIAGE`. Carried on `TimelineEntryDTO.derivedType`; `null` on curated-event entries exposed through the same DTO.
**derivedType** (`TimelineEntryDTO.derivedType`) `[internal]` — the `DerivedEventType` field distinguishing a derived Geburt/Tod/Heirat event from a curated one. Always non-null on derived events; `null` on curated events.
**assembleDerivedEvents()** (`TimelineEventService.assembleDerivedEvents()`) `[internal]` — the public `@Transactional(readOnly=true)` method that computes all derived events in one call: one batch fetch of family-member `Person`s via `PersonService.findAllFamilyMembers()` and one batch fetch of `SPOUSE_OF` edges via `RelationshipService.findAllSpouseEdges()`. Result is never persisted. Synthetic ids produced by this method (`birth:{uuid}`, `death:{uuid}`, `marriage:{uuid}`) are structurally non-UUID and must be rejected by any write endpoint. See ADR-043.
**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.
**Audit log** (`AuditLog`, table `audit_log`) — an append-only event store recording domain-level activity (document edits, user actions, etc.). Append-only by application convention; a `REVOKE UPDATE, DELETE` is attempted at the DB layer (see migrations V46, V47) but is a no-op if the application role is the table owner in PostgreSQL. Do not rely on DB-enforced immutability — the constraint is application-layer only.

View File

@@ -1,98 +0,0 @@
# ADR-039 — Person life dates become LocalDate + DatePrecision
**Status:** Accepted
**Date:** 2026-06-12
**Issue:** #773 (Zeitstrahl milestone, foundational)
## Context
`Person` stored `birthYear`/`deathYear` as `Integer`. A known exact birthday
(`1901-03-14`) had nowhere to live, and every display was stuck at year precision.
The Zeitstrahl's derived life-events need real dates with precision metadata, and the
document domain already solved exactly this problem with
`Document.documentDate` + `metaDatePrecision`.
V76 replaces the two integer columns with `birth_date`/`death_date` (`DATE`, nullable)
plus `birth_date_precision`/`death_date_precision` (`VARCHAR(16) NOT NULL DEFAULT
'UNKNOWN'`), backfilling existing years as `YYYY-01-01` at `YEAR` precision.
## Decisions
### 1. `DatePrecision` stays in `document/` and is imported cross-domain
The `person` package imports `org.raddatz.familienarchiv.document.DatePrecision`
directly. The layering rule (controllers → services → own repository) governs
service-to-repository coupling, **not** value-type sharing — an enum has no behaviour
and no persistence side effects. Creating a `common/` package for one enum would be
premature structure; if a third domain needs it, revisit then. The enum remains a
verbatim mirror of the import normalizer's `Precision` values (ADR-025) — changes must
stay in sync with `tools/import-normalizer/dates.py`.
### 2. Precision columns are NOT NULL with default `UNKNOWN`
Mirrors `Document.metaDatePrecision`. The illegal state "date present, precision null"
cannot exist: the named CHECK constraints
(`chk_person_birth_date_precision_coherence`, `…_values`,
`chk_person_birth_before_death`, and the death-side twins) enforce
`(date IS NULL) = (precision = 'UNKNOWN')` and the temporal order at the DB level;
`PersonService.validateLifeDates` enforces the same rules first so users get a
structured 400 (`INVALID_DATE_PRECISION` / `BIRTH_AFTER_DEATH`) instead of a
constraint-violation 500.
Storage accepts all seven `DatePrecision` values (enum-to-string mapping consistency),
but the person new/edit form offers only **DAY / MONTH / YEAR**`RANGE` and `SEASON`
are semantically nonsensical for a birth or death, and `APPROX` is excluded from the
form to reduce cognitive load for the senior author audience. Legacy `APPROX` rows
still render correctly (display delegates to `formatDocumentDate`).
The edit form seeds a stored non-offered precision (`APPROX`/`SEASON`/`RANGE`) into
the select as `YEAR`, so an untouched save coerces it to `YEAR` ("ca. 1944" becomes
"1944"). Accepted: nothing currently writes those precisions to persons (the form
offers DAY/MONTH/YEAR, the importer writes YEAR/UNKNOWN, V76 backfills YEAR), so the
case is only reachable via direct API writes — and seeding `YEAR` is strictly safer
than the alternative of silently claiming `DAY` precision.
### 3. Derived-year pattern for backward-compatible DTOs
`PersonNodeDTO` (Stammbaum) and `RelationshipDTO` keep `Integer birthYear/deathYear`,
derived null-safely in the relationship services (`birthDate != null ?
birthDate.getYear() : null` — never 0, never empty string; REQ-PERSON-DATE-01). The
native queries behind `PersonSummaryDTO` project
`CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear`.
### 4. `PersonSummaryDTO` intentionally exposes years only
The person list/search views show year precision only; full precision lives on the
person detail page. Do **not** add `LocalDate getBirthDate()` to the interface without
updating all four native queries (`findAllWithDocumentCount`,
`searchWithDocumentCount`, `findTopByDocumentCount`, `findByFilter`) — the interface
projection is satisfied purely by the SQL SELECT aliases.
### 5. `preferHumanDate` extends ADR-025's human-edit-preserve rule
The importer stays year-shaped: `PersonUpsertCommand` keeps `Integer birthYear/
deathYear` because the spreadsheet only knows a year — pushing `LocalDate` into the
importer would fabricate precision. On upsert, `PersonService.preferHumanDate` returns
a `DatePrecisionPair` record (date and precision travel as one value so they cannot go
out of sync):
- existing precision DAY/MONTH/SEASON/RANGE/APPROX → hand-entered, preserved verbatim;
- existing precision YEAR/UNKNOWN → refreshed from the canonical year as
`YYYY-01-01` + `YEAR` (or cleared to null/`UNKNOWN` when the sheet has no year).
The original integer/string `preferHuman` overloads remain for non-date fields.
### 6. Known limitation — mixed-precision comparison
`validateLifeDates` compares stored `LocalDate` values. A DAY-precision birth late in
the same year as a YEAR-precision death (stored as Jan 1st) is rejected with
`BIRTH_AFTER_DEATH`. This is intentional; the `error_birth_after_death` i18n message
carries the workaround hint (enter the following year as the death year).
## Consequences
- V76 is one-way (columns dropped). Rollback = targeted `pg_restore -t persons` from
the pre-deploy dump — see `docs/DEPLOYMENT.md` §5.
- Exact dates now render on person cards, hover cards, and the mention dropdown; the
Stammbaum and person list are visually unchanged.
- The Zeitstrahl can derive birth/death life-events at full precision.

View File

@@ -1,115 +0,0 @@
# ADR-040 — Timeline domain data model
**Status:** Accepted
**Date:** 2026-06-12
**Issue:** #774 (Zeitstrahl milestone, foundational)
## Context
The Zeitstrahl (family timeline) needs a home for *curated* events — births,
weddings, moves, and world-historical context a curator types in by hand, distinct
from the OCR-derived `Document` letters. This ADR commits the new `timeline` domain's
data model: the `TimelineEvent` entity, the `EventType` enum, a repository, and the
V77 migration. No service, controller, or DTO ships here — those land in later issues.
A `TimelineEvent` carries the same date block as `Document` (`eventDate` +
`precision` + `eventDateEnd`) so events and letters render through one path, but its
audit footprint deliberately diverges (see below).
## Decisions
### 1. New `timeline` domain package, separate from `geschichte`
Curated timeline events are their own concern, not a Lesereise/`Geschichte` subtype.
The domain owns `TimelineEvent`, `EventType`, and `TimelineEventRepository`.
### 2. Responses (#775) will be views, not serialized entities
`TimelineEvent` has two LAZY `ManyToMany` collections (`persons`, `documents`) and
`open-in-view` is `false` — exactly the shape that motivated ADR-036 for `geschichte`.
#775 must assemble `TimelineEventView`/`TimelineEventSummary` inside the service
transaction; a serialized entity is a 500 waiting to happen. Decided up front so it is
not retrofitted later.
### 3. `precision` reuses `document.DatePrecision` — imported, not duplicated
The `timeline` package imports `org.raddatz.familienarchiv.document.DatePrecision`
directly, the same cross-domain value-type sharing ADR-039 established for `person`.
An enum has no behaviour and no persistence side effects, so the layering rule
(services → own repository) does not govern it. The enum stays a verbatim mirror of the
import normalizer's `Precision` values (ADR-025); changes must stay in sync with
`tools/import-normalizer/dates.py`. Moving `DatePrecision` into a shared package is a
wider refactor (touching `Document`, `importing`, `person`) and its own future ADR.
### 4. `precision = UNKNOWN` is forbidden; every other value is legal
`eventDate` is NOT NULL — a curated event always has at least a year, so only OCR letters
fall into the "Ohne Datum" bucket. The CHECK `chk_timeline_event_precision`
(`date_precision <> 'UNKNOWN'`) forbids exactly that one value. `SEASON` ("Sommer 1914")
and `APPROX` ("ca. 1914") are explicitly legal — family memory is full of both, and the
spec's rendering table covers them. Do not narrow the CHECK to an allow-list; an
over-tight constraint would force curators to fake `YEAR` and render dishonest dates.
### 5. RANGE invariant is a strict biconditional at the DB, intentionally tighter than `Document`
`chk_timeline_event_range` enforces `(date_precision = 'RANGE') = (event_date_end IS NOT
NULL)``eventDateEnd` is non-null **iff** precision is `RANGE`, both directions. This is
*stricter* than `Document`'s open-ended ranges (which allow a null end on a RANGE) because a
curated event always has a known, closed end when it spans a range — it is authored, not
inferred. This divergence is deliberate: a future "bug fix" must not relax it to match
`Document`.
### 6. Audit trail: `@Version` + NOT NULL `createdBy`/`updatedBy`, diverging from `Document`
`Document` has neither a version nor a creator. A curated entity edited by multiple curators
warrants real protection, so `TimelineEvent` adds:
- `@Version Long version` — optimistic locking for the multi-curator edit flow (#775).
Object `Long` (not primitive) so it is `null` before first persist; Hibernate sets `0` on
insert. The service **must** catch `ObjectOptimisticLockingFailureException` and translate
it to `DomainException.conflict(...)`. Without that translation a concurrent-write conflict
surfaces as HTTP 500 with Hibernate internals in the body — information disclosure (CWE-209).
- `createdBy`/`updatedBy` as bare `UUID`, `NOT NULL`, no FK to `app_users` (sidecar pattern,
matching `DocumentAnnotation`/`OcrJob`; keeps `timeline` decoupled from `user`, avoids
lazy-load surprises in the read-heavy assembly path). NOT NULL makes a curated event with no
author impossible — an audit gap closed at the schema level. `DocumentAnnotation.createdBy`
is nullable and has no `updatedBy`; the escalation here is deliberate because curated events
are multi-author. Curator display names resolve through `UserService` at render time.
`updatedBy` is **not** advanced by `@UpdateTimestamp` — the service must set it from the
session principal before every `save()`, or the timestamp moves while the "who" goes stale.
### 7. `createdBy`/`updatedBy` are server-populated only — never bound from client input
Both are set from the session principal in the service, never from a request body. Binding
them from client input is an authorship-forgery / mass-assignment vector (CWE-639). #775's
regression suite must include forgery cases on **both** write paths (`POST` body with
`createdBy`, `PUT` body with `updatedBy`) — create and update are separate binding paths, so
testing only one leaves half the vector open. The update test must assert `updatedBy` equals
the *second* editor's UUID, not merely non-null.
### 8. `EventType` string values are a stable frontend styling contract
The Tailwind class map in the timeline Svelte components hard-codes `PERSONAL` (family accent)
and `HISTORICAL` (muted world accent) as strings. There is no mapping layer — renaming either
value requires a coordinated frontend change. Recorded here to prevent a silent regression.
### 9. Explicit `@JoinTable` on both ManyToMany fields
Without explicit `@JoinTable(name, joinColumns, inverseJoinColumns)`, Hibernate's naming
strategy could diverge from the V77 DDL's explicit table/column names. Explicit mapping
guarantees alignment and makes future column renames a deliberate, visible change. All four FK
columns are `ON DELETE CASCADE`: deleting a Person or Document drops the join row and leaves
the event intact (V71/ADR-032 hardening — a person delete must never 500).
## Consequences
- V77 is forward-only; rollback is manual DDL (`DROP TABLE` the two join tables, then
`timeline_events`). No rollback script, no rollback test.
- The `timeline → document.DatePrecision` compile coupling is permanent until a shared-package
refactor; precedent already exists (`importing/DocumentImporter`, `person`).
- The service/controller/DTO layer (#775) inherits the view-assembly, optimistic-lock
translation, forgery-guard, and permission obligations recorded above. It must also add a
service-level title-length check (new `ErrorCode`, e.g. `TIMELINE_TITLE_TOO_LONG`, mirroring
`GESCHICHTE_TITLE_TOO_LONG`) — `title` is `VARCHAR(255)`, and without the guard an over-long
title surfaces as a raw `DataIntegrityViolationException` → HTTP 500.

View File

@@ -1,123 +0,0 @@
# ADR-041 — Renovate runner stand-up: two-token model, OSV surfacing, digest pinning
**Date:** 2026-06-13
**Status:** Accepted
**Issue:** [#818](https://git.raddatz.cloud/marcel/familienarchiv/issues/818)
---
## Context
Issue #817 (esbuild/cookie advisory) revealed that `main` had no early-warning
mechanism for newly-published advisories. An advisory landed against already-pinned
versions, turned the `npm audit --audit-level=high --omit=dev` gate red on `main`,
and then ambushed the next unrelated PR (#774). The author who hit it did not cause
it and had no warning.
`renovate.json` existed but `renovatebot` had never actually run: there was no
`.gitea/workflows/renovate.yml` and zero Renovate-authored PRs in the repo's entire
history. The three `packageRules` (bucket4j / tiptap / privileged-digest) were
silently inert.
This ADR records the **negative space** — why specific design choices were made,
so future maintainers do not "tidy up" toward a worse outcome.
---
## Decision
### Why there is no auto-provided `GITEA_TOKEN`
Self-hosted Gitea runners do not auto-inject a `GITEA_TOKEN` equivalent.
`docs/infrastructure/ci-gitea.md` (and its current line ~251) explicitly states the
token "must be created manually." No existing workflow in this repo references
`GITEA_TOKEN` for API calls — only for container registry auth (`docker login`).
Both `RENOVATE_TOKEN` and `NIGHTLY_AUDIT_TOKEN` must be manually provisioned as
Gitea secrets by a repository admin.
### Why two tokens, not one
The two jobs have different blast radii on token compromise:
| Token | Scopes | Used by |
|-------|--------|---------|
| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | Renovate — must read/write files and open PRs |
| `NIGHTLY_AUDIT_TOKEN` | `issues` only | Nightly audit — only needs to file a tracking issue |
The nightly job's token appears in step `env:` and is passed to `curl -H`. A leak via
runner logs, process arguments, or a misconfigured step would expose the token.
An `issues`-only token cannot push branches, open PRs, or read repository contents —
the leaked token's blast radius is limited to creating/editing issues.
A single broad token would give any leak path full `contents` + `pull_request` write
access to the repository. That risk is asymmetric with the upside (one fewer secret).
Both tokens belong to one dedicated bot account (consistent authorship; one identity
to audit and rotate). **Branch protection on `main` must forbid the bot pushing
directly**, because a `contents`-scoped token can push to any unprotected branch.
### Why the Renovate action is digest-pinned
`renovatebot/github-action` executes with the `RENOVATE_TOKEN` in scope. That token
carries `contents` + `pull_request` + `issues` — enough to read files, open PRs, and
write issues. An unpinned `@v40` tag can be re-pointed by the upstream maintainer
(or a compromised maintainer account) at any time. A pinned digest (`@<sha>`) cannot
be silently modified; the SHA is immutable. This is the same threat model applied to
all privileged CI steps in this repo (see the `matchFileNames` rule in `renovate.json`
for `.gitea/workflows/**`).
Renovate itself will open a PR to bump the digest when a new release ships, which is
the intended update path.
### Why `osvVulnerabilityAlerts` is the load-bearing detector on Gitea
Renovate's `vulnerabilityAlerts` config key triggers off a *platform* vulnerability
graph. GitHub exposes the GitHub Advisory Database via its API; **Gitea does not
expose an equivalent vulnerability graph**. On self-hosted Gitea, `vulnerabilityAlerts`
is effectively a label carrier — it attaches the configured labels to PRs that
`osvVulnerabilityAlerts` already detected, but it is not an independent detector.
`osvVulnerabilityAlerts: true` is the load-bearing flag: Renovate queries
[OSV.dev](https://osv.dev) directly (platform-agnostic). The runner host must be able
to reach OSV.dev over HTTPS — if egress is filtered, allow `osv.dev:443` or the flag
silently no-ops.
### Why the root `schedule` does not mute security PRs
`"schedule": ["before 6am on monday"]` in `renovate.json` batches **routine** dependency
updates (version bumps outside any security context) to a weekly window. This reduces
noise from routine update PRs while still allowing review before merge.
**Security and vulnerability PRs bypass the schedule by design** — Renovate raises
them immediately regardless of the schedule window. A future "tidy-up" that removes
or widens the schedule cannot mute vulnerability alerts; this is worth stating
explicitly to prevent that misunderstanding.
### Why `lockFileMaintenance` has no `automerge`
`lockFileMaintenance` refreshes transitive pins weekly so the dependency tree drifts
into fewer advisories over time. It is explicitly set without `automerge: true` because
a weekly transitive pin refresh can silently break the build if a transitive dep
introduces a breaking change. These PRs are small and should be reviewed.
### Why there is no entry in `l2-containers.puml`
`docs/architecture/c4/l2-containers.puml` documents long-lived infrastructure
containers (services that run continuously). Renovate is a scheduled CI job that runs
on a Gitea Actions runner and exits — it is not a long-lived container. Adding it to
the container diagram would misrepresent the architecture. This omission is deliberate,
not an oversight.
---
## Consequences
- Newly-published advisories against our frontend dependencies are surfaced within
one day (daily Renovate cron) rather than at the next contributor PR.
- A nightly `npm audit` job provides an independent signal for dev-dependency advisories
that Renovate may not cover via OSV.
- Two secrets (`RENOVATE_TOKEN`, `NIGHTLY_AUDIT_TOKEN`) must be manually provisioned
and rotated annually (or on suspected compromise). See
`docs/infrastructure/ci-gitea.md` for the runbook.
- The bot account must be kept active and branch protection on `main` must forbid
it pushing directly. These are operational prerequisites, not code invariants.

View File

@@ -1,94 +0,0 @@
# ADR-042 — Adopt Spec-Driven Development (SDD)
**Status:** Accepted
**Date:** 2026-06-13
**Issue:** SDD integration (docs/sdd-integration branch)
> This is the "ADR-000" the SDD scaffold refers to, numbered 042 to fit the existing archive
> sequence (041 was taken by the Renovate runner-setup ADR merged in parallel). See
> [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
## Context
The project already runs a lightweight, multi-persona review loop: feature work is tracked
as dense Gitea issues, and the `review-issue` / `review-pr` / `deliver-issue` skills dispatch
the character personas in `.claude/personas/` (requirements engineer, developer, security,
devops, ui, architect, tester) to comment on issues and PRs. `COLLABORATING.md` already
mandates a Research → Plan → Implement → Validate cycle, red/green TDD, and a User-Journey +
E2E-Scenario section on every feature issue. Decisions are captured in this ADR archive.
What is missing is a **machine-readable, uniform front-end to that workflow**:
- Requirements are written in free prose, so two readers (and an AI agent) can interpret the
same issue differently. There is no enforced requirement grammar and no stable requirement
identifier to trace from spec → code → test.
- There is no single short file an AI coding agent reads on every invocation; the rules are
spread across `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, and `CONTRIBUTING.md`.
- Persona review is valuable but ad-hoc — there is no per-role checklist that gates a spec
*before* implementation, so blind spots surface during PR review instead of at spec time.
- There is no living traceability record and no CI signal that a spec is well-formed.
The project is solo + LLM-driven; spec density and machine-readability are exactly the
leverage points that make agent output reliable.
## Decision
Adopt Spec-Driven Development, layered **on top of** the existing workflow, not replacing it:
1. **EARS requirements.** Every feature requirement is written in one of the five EARS
patterns and carries a per-feature `REQ-NNN` id. (constitution §3; templates/feature-spec.md)
2. **The spec lives in the Gitea issue body — issue-only, no committed `spec.md`.** A feature's
spec (EARS requirements, acceptance criteria, scope) is authored and reviewed in the Gitea
issue, the single source of truth (consistent with the established "issue body is the source
of truth / don't commit standalone spec files" practice). The only per-feature artifact in
git is the RTM row (`REQ-ID → issue # → test`). Durable design decisions still get a
`docs/adr/` ADR; an OpenAPI contract or STRIDE threat model is drafted inline in the issue
using the `.specify/templates/` as writing aids. `.specify/features/_example/` is a committed
template/reference, not a live feature.
3. **A constitution + AGENTS.md.** `.specify/constitution.md` records the few non-negotiable
rules (semantically versioned); `.specify/AGENTS.md` is the short machine-readable file AI
agents read every invocation, cross-referencing the constitution rather than duplicating it.
4. **Persona checklists.** `.specify/personas/*.md` turn the existing rich personas into
concise, EARS-aware, pass/fail spec-review checklists that gate a spec before code.
5. **Living RTM + CI gate.** `.specify/rtm.md` traces every `REQ-NNN` to its issue and test;
`.gitea/workflows/sdd-gate.yml` validates any committed OpenAPI contract, lints any committed
spec (e.g. the `_example`), reports constitution-change impact, and surfaces RTM drift
(non-blocking initially).
6. **Reuse, don't duplicate.** ADRs stay in `docs/adr/`; persona checklists reference
`.claude/personas/`; the Gitea issue templates mirror the feature-spec template.
## Alternatives Considered
| Option | Pros | Cons | Reason rejected |
|---|---|---|---|
| Adopt SDD (EARS requirements in the issue + AGENTS.md + constitution), integrated with existing docs | Adds requirement rigor & machine-readability; reuses ADR archive, personas, and the issue-as-spec practice; incremental | Per-feature spec authoring overhead | **Chosen** |
| No change (keep free-prose issues + persona review) | Zero new process | Ambiguous requirements; no traceability; no single agent-facing file; blind spots found late | Leaves the exact gaps that hurt agent output |
| Full GitHub Spec Kit | Mature, opinionated tooling | Heavy, opinionated structure that fights the existing Gitea/skill/persona workflow; redundant ADR/persona machinery | Too much to retrofit; conflicts with what already works |
| BMAD-METHOD | Rich agent-role framework | Large conceptual surface for a solo project; overlaps the existing persona system | Over-engineered for this team size |
## Consequences
- **Cost:** ~3060 min of spec authoring + structured persona review per feature, before
implementation begins.
- **Gains:** unambiguous, testable requirements; a stable spec→code→test trace; one
authoritative agent-facing rules file; review blind spots caught at spec time; better and
more consistent AI-agent output (the project's primary delivery mechanism).
- **Obligations created:**
- The constitution is semantically versioned; any change triggers its Sync Impact review.
- A new feature follows the workflow in [SPEC_DRIVEN_DEVELOPMENT.md](../../SPEC_DRIVEN_DEVELOPMENT.md).
- `rtm.md` is kept in sync as requirements land (CI warns on drift).
- The SDD CI jobs start non-blocking and flip to blocking once adoption settles (TODO noted
in the workflow).
- **Non-disruptive:** the existing Gitea-issue, branch/PR, TDD, and persona-review practices
are unchanged in spirit — SDD formalises their inputs, it does not replace them.
## References
- [.specify/constitution.md](../../.specify/constitution.md), [.specify/AGENTS.md](../../.specify/AGENTS.md)
- [SPEC_DRIVEN_DEVELOPMENT.md](../../SPEC_DRIVEN_DEVELOPMENT.md)
- [COLLABORATING.md](../../COLLABORATING.md), [CONTRIBUTING.md](../../CONTRIBUTING.md)
- EARS: Mavin et al., "Easy Approach to Requirements Syntax" (2009)
## Revision log
- 2026-06-13 — constitution v1.0.0 ratified with this ADR.

View File

@@ -1,110 +0,0 @@
# ADR-043 — Derived person life-events: on-read assembly strategy
**Status:** Proposed
**Date:** 2026-06-13
**Issue:** #776 — Timeline: derive person life-events (Geburt/Tod/Heirat)
---
## Context
The Zeitstrahl (family timeline) must surface births, deaths, and marriages alongside
manually curated `TimelineEvent` rows. This data already exists in the `Person` entity
(`birthDate`, `deathDate`, `birthDatePrecision`, `deathDatePrecision`) and in
`PersonRelationship` rows with `relationType = SPOUSE_OF`.
Three architectural decisions needed before implementation could start:
1. **Computation strategy:** should derived events be materialised to the `timeline_events`
table, or assembled on every read from the source tables?
2. **Id format:** how do we give derived events stable, unambiguous ids that cannot collide
with real `TimelineEvent` UUIDs and signal read-only semantics to consumers?
3. **Service contract:** where does the assembly method live, and what is its public API?
---
## Decision 1 — On-read assembly, never persisted
Derived events are computed on every call to `assembleDerivedEvents()` and are never written
to any table.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Materialise to `timeline_events` | Requires a synchronisation job or domain-event wiring every time a `Person` or `PersonRelationship` is mutated. Adds complexity, drift risk, and a write path for data that is fundamentally derived. |
| Separate `derived_events` table | Same sync problem; adds schema migration for data that is a pure projection. |
| Cache in-process | Adds invalidation complexity for MVP scale (tens to low hundreds of persons). Can be added later if `findAllFamilyMembers()` exceeds ~500 rows. |
**Consequences:**
- No schema changes. No Flyway migration.
- The method must be `@Transactional(readOnly = true)` to keep the Hibernate session open
across the lazy-association reads that `buildMarriageEvents()` performs via JOIN FETCH.
- Every caller of `assembleDerivedEvents()` triggers two DB queries: one for family-member
persons, one for spouse edges with JOIN FETCH. Acceptable at MVP scale.
---
## Decision 2 — Synthetic prefixed String ids
Derived events receive ids of the form `birth:{personId}`, `death:{personId}`,
`marriage:{relationshipId}`, where the suffix is the UUID of the source entity.
**Format rules:**
- `id` field on `TimelineEntryDTO` is typed `String`, NOT `UUID`.
- `UUID.fromString(derivedEvent.id())` always throws `IllegalArgumentException` — id is
structurally non-UUID by construction.
- The `unique_spouse_pair` DB index (V55) is the authoritative dedup guard for marriages;
the in-memory `Set<UUID>` used during assembly is a defensive assertion, not primary
enforcement.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Random UUID for each call | Not stable across calls — consumers (frontend, #5 sort/bucket) could not use ids as stable keys. |
| UUID typed field with a sentinel namespace (RFC 4122 v5) | Requires hashing; still looks like a UUID and could be confused with real event ids by write endpoints. |
| Numeric sequence | No natural source sequence; would require a counter, adding state. |
**Consequences:**
- `TimelineEntryDTO.id` must be `String`. The existing `TimelineEventView.id` is `UUID` and
serves a different purpose (CRUD admin view); it is not changed.
- Any write endpoint that accepts a timeline event id (`PUT`, `DELETE`) must reject ids that
do not parse as `UUID` — enforced and tested in issue #5, not here.
- Ids are deterministic and stable for the lifetime of the source entity, enabling client-side
caching and deduplication.
---
## Decision 3 — `assembleDerivedEvents()` as the public cross-issue contract
The assembly method lives on `TimelineService` as a `public` method. Issue #5 (the
`GET /api/timeline` endpoint) calls it directly on the injected `TimelineService` bean.
**Domain boundary rules enforced by this decision:**
- `TimelineService` reaches `Person` and `PersonRelationship` data **only through
`PersonService.findAllFamilyMembers()` and `RelationshipService.findAllSpouseEdges()`**.
It never injects `PersonRepository` or `PersonRelationshipRepository`.
- The three private builder methods (`buildBirthEvents`, `buildDeathEvents`,
`buildMarriageEvents`) are implementation details; only `assembleDerivedEvents()` is public.
- **Authorization:** `assembleDerivedEvents()` performs no authorization check. The calling
endpoint in #5 must enforce `READ_ALL` before invoking this method. Any future caller
outside #5 must do the same — this obligation is documented in the Javadoc of the method.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Separate `DerivedEventService` | Adds a class for a cohesive set of methods that belong to the timeline domain. Timeline owns the DTO shape; splitting it out is premature. |
| Expose via `PersonService` | Person domain should not know about `TimelineEntryDTO`. Cross-cutting concern belongs in timeline. |
---
## Related decisions
- ADR-039 — Person life-dates stored as `LocalDate` + `DatePrecision` (the source data this
issue reads)
- ADR-040 — Timeline domain data model (establishes the `timeline/` package and
`TimelineEvent` entity this issue extends)
- ADR-036 — Responses as views, never raw entities (why `assembleDerivedEvents()` returns
`List<TimelineEntryDTO>`, not raw `Person` or `PersonRelationship` entities)

View File

@@ -1,33 +0,0 @@
@startuml
!include <C4/C4_Component>
title Component Diagram: API Backend — Timeline (Zeitstrahl)
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents).")
Component(timelineSvc, "TimelineEventService", "Spring Service", "Owns curated-event CRUD: assembles TimelineEventView inside the transaction (lazy ManyToMany + open-in-view=false, ADR-036/ADR-040), populates createdBy/updatedBy from the session principal, and translates optimistic-lock conflicts to DomainException.conflict. Also exposes assembleDerivedEvents(): computes Geburt/Tod/Heirat TimelineEntryDTOs on read from Person/PersonRelationship data — never persisted (ADR-043).")
Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline/events reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
Component(timelineAssemblySvc, "TimelineService", "Spring Service", "Assembles GET /api/timeline response: merges curated TimelineEvent rows, derived life-events (via TimelineEventService), and archive letters (via DocumentService) into a year-bucketed TimelineDTO. Applies personId, generation, type, fromYear/toYear filters. WITHIN_BAND_ORDER: precision rank desc → date asc → title alpha → id tiebreak.")
Component(timelineAssemblyCtrl, "TimelineController", "Spring MVC", "Exposes GET /api/timeline (READ_ALL). Five optional query params: personId, generation (@Min(0)), type (EventType enum), fromYear, toYear. @Validated on class for constraint enforcement.")
}
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type), Document references for linked letters, and getAllForTimeline() bulk fetch")
System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers, getPersonsByGeneration, getById) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and generation filtering")
Rel(timelineRepo, db, "SQL queries", "JDBC")
Rel(timelineSvc, timelineRepo, "Reads / writes events")
Rel(timelineCtrl, timelineSvc, "Delegates to")
Rel(timelineRepo, personDomain, "References persons via join table")
Rel(timelineRepo, documentDomain, "References documents via join table")
Rel(timelineSvc, personDomain, "findAllFamilyMembers() + findAllSpouseEdges() for derived-event assembly")
Rel(timelineAssemblyCtrl, timelineAssemblySvc, "Delegates to")
Rel(timelineAssemblySvc, timelineRepo, "findAll() for curated events")
Rel(timelineAssemblySvc, timelineSvc, "assembleDerivedEvents() for derived life-events")
Rel(timelineAssemblySvc, personDomain, "getPersonsByGeneration(), getById() for generation/personId filters")
Rel(timelineAssemblySvc, documentDomain, "getAllForTimeline(), getDocumentsBySender(), getDocumentsByReceiver() for letter layer")
@enduml

View File

@@ -14,8 +14,6 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
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 incl. StoryDocumentPanel: document linking via POST/DELETE /items); 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(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.")
Component(zeitstrahlEvents, "/zeitstrahl/events/new and /zeitstrahl/events/[id]/edit", "SvelteKit Routes", "Curator event editor (WRITE_ALL-gated via server load, 403 error page). One lib/timeline/EventForm for both routes: title, EventTypeSelect (PERSONAL/HISTORICAL segmented radio), shared DatePrecisionField (RANGE reveals end date), plain-text description, PersonMultiSelect + DocumentMultiSelect. New: ?personId/?documentId prefill via Promise.all (404/403 swallowed), POST /api/timeline/events. Edit: load seeds from GET /api/timeline/events/{id} (404 on any non-ok — fails closed against derived events), PUT (optimistic-lock version) + DELETE behind ConfirmDialog. Context-aware redirect via UUID-validated originPersonId.")
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.")
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
@@ -29,9 +27,6 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
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(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser")
Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON")
Rel(zeitstrahlEvents, backend, "GET /api/timeline/events/{id}, POST /api/timeline/events, PUT/DELETE /api/timeline/events/{id}, GET /api/persons/{id} + /api/documents/{id} (prefill)", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")

View File

@@ -1,6 +1,6 @@
@startuml db-orm
' Schema source: Flyway V1V77 (excl. V37, V43 — intentionally removed)
' Schema as of: V77 (2026-06-12)
' 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
@@ -184,10 +184,8 @@ package "Persons" {
title : VARCHAR(50)
person_type : VARCHAR(20) NOT NULL
notes : TEXT
birth_date : DATE
birth_date_precision : VARCHAR(16) NOT NULL
death_date : DATE
death_date_precision : VARCHAR(16) NOT NULL
birth_year : INTEGER
death_year : INTEGER
generation : SMALLINT
family_member : BOOLEAN NOT NULL
source_ref : VARCHAR(255) UNIQUE
@@ -386,39 +384,6 @@ package "Supporting" {
}
}
' ── Timeline (Zeitstrahl) ──
package "Timeline" {
entity timeline_events {
id : UUID <<PK>>
--
title : VARCHAR(255) NOT NULL
type : VARCHAR(16) NOT NULL
event_date : DATE NOT NULL
date_precision : VARCHAR(16) NOT NULL DEFAULT 'YEAR'
event_date_end : DATE
description : TEXT
created_by : UUID NOT NULL
created_at : TIMESTAMP
updated_by : UUID NOT NULL
updated_at : TIMESTAMP
version : BIGINT
==
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL))
CHECK (date_precision <> 'UNKNOWN')
}
entity timeline_event_persons {
timeline_event_id : UUID <<FK>>
person_id : UUID <<FK>>
}
entity timeline_event_documents {
timeline_event_id : UUID <<FK>>
document_id : UUID <<FK>>
}
}
' Auth relationships
app_users_groups }o--|| app_users : app_user_id
app_users_groups }o--|| user_groups : group_id
@@ -482,10 +447,4 @@ geschichten_persons }o--|| persons : person_id
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
' Timeline relationships
timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE)
timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE)
@enduml

View File

@@ -4,9 +4,6 @@
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
' Note: V69 adds columns only (persons.source_ref, tag.source_ref, document
' precision/attribution fields); no new FK relationships, so this diagram is unchanged.
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
' precision columns; columns only, no new FK relationships, diagram unchanged.
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
hide circle
skinparam linetype ortho
@@ -72,13 +69,6 @@ package "Supporting" {
entity journey_items
}
' ── Timeline (Zeitstrahl) ──
package "Timeline" {
entity timeline_events
entity timeline_event_persons
entity timeline_event_documents
}
' Auth relationships
app_users_groups }o--|| app_users : app_user_id
app_users_groups }o--|| user_groups : group_id
@@ -144,11 +134,4 @@ 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)
note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75)
' Timeline relationships (V77)
timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE)
timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE)
note right of timeline_events : CHECK event_date_end non-null IFF RANGE\nCHECK date_precision <> 'UNKNOWN' (V77)
@enduml

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