Timeline: TimelineEvent CRUD API (#775) #822

Merged
marcel merged 12 commits from feat/issue-775-timeline-crud-api into main 2026-06-13 12:29:49 +02:00
Owner

Closes #775.

Curator CRUD for TimelineEvent, layered Controller → Service → Repository on the entity merged by #774. Backend-only except the regenerated API types + i18n. TDD throughout; 62 targeted tests green.

Endpoints

  • POST /api/timeline/events201 + TimelineEventView · @RequirePermission(WRITE_ALL)
  • PUT /api/timeline/events/{id}200 + updated view · @RequirePermission(WRITE_ALL)
  • DELETE /api/timeline/events/{id}204 · @RequirePermission(WRITE_ALL)
  • GET /api/timeline/events/{id}200 + view · global-auth READ baseline (no annotation, documented)

Highlights

  • Views, never entitiesTimelineEventView + PersonView + timeline-local DocumentRef (R7), assembled in-tx after flush; explicit allow-list (no notes/provisional leak).
  • Audit fields server-setcreatedBy+updatedBy on create, only updatedBy on update (preserves creator); createdBy/updatedBy absent from the DTO (CWE-639).
  • Link resolution — dedupe-first, fail-closed; persons via PersonService.getAllById + distinct-size check, documents via per-id getDocumentById loop.
  • Guards — RANGE invariant (both directions), service title-length guard, YEAR date normalization, default precision.
  • Conflict handling — service catch → TIMELINE_EVENT_CONFLICT; centralized GlobalExceptionHandler backstop → generic 409 CONFLICT, no Sentry, class-name-only logging (CWE-209).

⚠️ Deviation from the issue body (intentional, maintainer-approved)

The integration test #775 mandated to "prove the lock engages end-to-end" caught that the spec's prescribed mechanism — event.setVersion(clientVersion) then saveAndFlush on a managed entity — does not engage Hibernate's optimistic lock (Hibernate ignores a manually-set @Version on a managed entity and uses its own loaded-version snapshot for the WHERE version=? clause), so a stale write silently succeeds.

Replaced with requireVersionMatch: an explicit compare of the client's last-seen token against the freshly-loaded version (the true semantics of the Q1 client-supplied-token decision). The native @Version increment still fires on every save, and saveAndFlush+catch remains the backstop for genuinely concurrent flushes. Null token ⇒ last-write-wins. Conflict codes unchanged. The issue body still describes the original setVersion wording per maintainer direction — the code is the corrected source of truth here.

Tests (62 green)

TimelineEventServiceTest (23) · TimelineEventControllerTest (19) · TimelineEventServiceIntegrationTest (2, Testcontainers — view-assembly LazyInit guard + no-leak JSON + real 409) · GlobalExceptionHandlerTest (5) · ArchitectureTest (13).

./mvnw clean package -DskipTests · frontend lint · regenerated frontend/src/lib/generated/api.ts committed (no CI drift guard).

Docs

R9 ErrorCode catalog sync in CLAUDE.md + docs/ARCHITECTURE.md; frontend errors.ts + de/en/es i18n. Diagrams/GLOSSARY/ADR-040 were done by #774.

🤖 Generated with Claude Code

Closes #775. Curator CRUD for `TimelineEvent`, layered `Controller → Service → Repository` on the entity merged by #774. Backend-only except the regenerated API types + i18n. TDD throughout; 62 targeted tests green. ## Endpoints - `POST /api/timeline/events` → **201** + `TimelineEventView` · `@RequirePermission(WRITE_ALL)` - `PUT /api/timeline/events/{id}` → **200** + updated view · `@RequirePermission(WRITE_ALL)` - `DELETE /api/timeline/events/{id}` → **204** · `@RequirePermission(WRITE_ALL)` - `GET /api/timeline/events/{id}` → **200** + view · global-auth READ baseline (no annotation, documented) ## Highlights - **Views, never entities** — `TimelineEventView` + `PersonView` + timeline-local `DocumentRef` (R7), assembled in-tx after flush; explicit allow-list (no `notes`/`provisional` leak). - **Audit fields server-set** — `createdBy`+`updatedBy` on create, only `updatedBy` on update (preserves creator); `createdBy`/`updatedBy` absent from the DTO (CWE-639). - **Link resolution** — dedupe-first, fail-closed; persons via `PersonService.getAllById` + distinct-size check, documents via per-id `getDocumentById` loop. - **Guards** — RANGE invariant (both directions), service title-length guard, YEAR date normalization, default precision. - **Conflict handling** — service catch → `TIMELINE_EVENT_CONFLICT`; centralized `GlobalExceptionHandler` backstop → generic 409 `CONFLICT`, no Sentry, class-name-only logging (CWE-209). ## ⚠️ Deviation from the issue body (intentional, maintainer-approved) The integration test #775 mandated to "prove the lock engages end-to-end" caught that the spec's prescribed mechanism — `event.setVersion(clientVersion)` then `saveAndFlush` on a **managed** entity — **does not engage Hibernate's optimistic lock** (Hibernate ignores a manually-set `@Version` on a managed entity and uses its own loaded-version snapshot for the `WHERE version=?` clause), so a stale write silently succeeds. Replaced with `requireVersionMatch`: an explicit compare of the client's last-seen token against the freshly-loaded version (the true semantics of the Q1 client-supplied-token decision). The native `@Version` increment still fires on every save, and `saveAndFlush`+catch remains the backstop for genuinely concurrent flushes. Null token ⇒ last-write-wins. Conflict codes unchanged. The issue body still describes the original `setVersion` wording per maintainer direction — the code is the corrected source of truth here. ## Tests (62 green) `TimelineEventServiceTest` (23) · `TimelineEventControllerTest` (19) · `TimelineEventServiceIntegrationTest` (2, Testcontainers — view-assembly LazyInit guard + no-leak JSON + real 409) · `GlobalExceptionHandlerTest` (5) · `ArchitectureTest` (13). `./mvnw clean package -DskipTests` ✅ · frontend lint ✅ · regenerated `frontend/src/lib/generated/api.ts` committed (no CI drift guard). ## Docs R9 ErrorCode catalog sync in `CLAUDE.md` + `docs/ARCHITECTURE.md`; frontend `errors.ts` + de/en/es i18n. Diagrams/GLOSSARY/ADR-040 were done by #774. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 11 commits 2026-06-13 11:36:07 +02:00
Add TIMELINE_EVENT_NOT_FOUND (404), TIMELINE_EVENT_CONFLICT (409),
TIMELINE_TITLE_TOO_LONG (400), and a generic CONFLICT (409) used by the
optimistic-lock backstop. Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flat input DTO with Bean Validation (@NotBlank/@NotNull/@Size). createdBy/
updatedBy deliberately absent (server-populated; CWE-639). version is an
optional concurrency token, exempt from the server-only audit rule. Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TimelineEventView + nested PersonView + timeline-local DocumentRef. Explicit
field allow-list, never the raw entity (lazy-collection 500 + curator-field
leak). DocumentRef stays timeline-local by design (#775 R7). Per ADR-040 §2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create/update/delete write methods (@Transactional) + getEvent read
(@Transactional(readOnly=true) for the LazyInit guard). Persons resolved via
PersonService.getAllById with a distinct-size check; documents via per-id
DocumentService.getDocumentById loop; both dedupe-first, fail-closed. RANGE
invariant (both directions), title-length guard, YEAR date normalization, and
default precision. Audit fields server-set (createdBy+updatedBy on create;
only updatedBy on update). Optimistic-lock conflict translated to
TIMELINE_EVENT_CONFLICT via saveAndFlush+catch. Views assembled after flush.

Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST→201, PUT→200, DELETE→204, GET→200; @RequirePermission(WRITE_ALL) on the
three writes, GET via global auth baseline (no annotation, documented). @Valid
request body; all bodies are TimelineEventView. Injects UserService + private
requireUserId wrapper. Controller slice tests cover 401/403/exact-status per
verb, GET 404, service PERSON_NOT_FOUND→404, Bean-Validation 400s carrying
code=VALIDATION_ERROR, and ArgumentCaptor proof that actorId is the resolved
session principal (not a forged body field) on both write paths.

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Centralized @ExceptionHandler(ObjectOptimisticLockingFailureException) net so
any write path losing a @Version race becomes a generic 409 (CONFLICT code) —
never a 500 + Sentry + Hibernate internals (CWE-209). No Sentry, class-name-
only parameterized logging, body free of id/version/class. Entity-agnostic by
design (no switch on getPersistentClassName); the service catch keeps the
precise TIMELINE_EVENT_CONFLICT. Per #775 Q2/R4/R8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spec's prescribed mechanism (load managed entity -> setVersion(clientVersion)
-> saveAndFlush -> catch ObjectOptimisticLockingFailureException) does NOT engage
the lock: Hibernate ignores a manually-set @Version on a managed entity and uses
its own loaded-version snapshot for the UPDATE ... WHERE version=? clause, so a
stale client write silently succeeds. The integration test the issue mandated to
'prove the lock engages end-to-end' caught exactly this.

Replace it with requireVersionMatch: an explicit compare of the client's
last-seen token against the freshly-loaded version (the true semantics of the Q1
client-supplied-token decision). The native @Version increment still fires on
every save, and the saveAndFlush+catch is retained as the backstop for two
transactions flushing concurrently. Null token => last-write-wins, unchanged.

Deviation from #775's reviewed setVersion mechanism (per maintainer direction the
issue body is left as-is); version unit tests updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two service-level integration tests against real Postgres (V77 CHECKs are
Postgres-specific): (1) view-assembly round-trip proving the
@Transactional(readOnly=true) LazyInit guard populates persons/documents after
an em.clear()ed fresh getEvent, with a serialized-JSON assertion that no
notes/provisional/password leak; (2) real optimistic-lock 409 — editor B's
stale version yields TIMELINE_EVENT_CONFLICT end-to-end (the unit test only
proves the catch/guard branches).

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R9 doc-sync: add TIMELINE_EVENT_NOT_FOUND, TIMELINE_EVENT_CONFLICT,
TIMELINE_TITLE_TOO_LONG, and the generic CONFLICT to the valid-error-codes
list in CLAUDE.md and the error-code reference in docs/ARCHITECTURE.md. Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
errors.ts ErrorCode union + getErrorMessage() cases for the four new codes,
with de/en/es i18n keys. Conflict messages are calm/recoverable
('...wurde zwischenzeitlich geändert. Bitte neu laden.'). Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chore(timeline): regenerate API types for event CRUD endpoints
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m49s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m16s
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 1m6s
f51f195b36
Regenerated frontend/src/lib/generated/api.ts from the OpenAPI spec — adds the
/api/timeline/events paths and TimelineEventRequest/TimelineEventView schemas.
CI has no OpenAPI drift guard, so the regen is committed here. (Operation-id
churn create->create_1 etc. is cosmetic; the typed client keys off paths, not
operation ids; the timeline PersonView merges with geschichte's identical one.)

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marcel added 1 commit 2026-06-13 12:02:02 +02:00
fix(timeline): reject reversed RANGE events; thread precision
All checks were successful
CI / Backend Unit Tests (pull_request) Successful in 5m44s
CI / fail2ban Regex (pull_request) Successful in 52s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m11s
CI / Unit & Component Tests (pull_request) Successful in 4m55s
CI / OCR Service Tests (pull_request) Successful in 26s
9195bcbd7e
The DB CHECK chk_timeline_event_range enforces only the presence
biconditional (eventDateEnd non-null IFF RANGE), not date ordering, so a
RANGE event with eventDateEnd before eventDate persisted silently and
rendered as a negative span. validateRangeInvariant now also rejects
end-before-start (INVALID_DATE_RANGE); equal dates remain a valid one-day
closed range.

Also compute effectivePrecision once per create/update and thread it into
validateRangeInvariant and applyUpdate instead of recomputing.

Addresses review of #822 (#775).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marcel merged commit 210dde6562 into main 2026-06-13 12:29:49 +02:00
marcel deleted branch feat/issue-775-timeline-crud-api 2026-06-13 12:29:50 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#822