Timeline: TimelineEvent CRUD API #775

Open
opened 2026-06-07 19:28:51 +02:00 by marcel · 7 comments
Owner

Milestone: Zeitstrahl — Family Timeline
Spec: docs/superpowers/specs/2026-06-07-family-timeline-design.md § "Assembly & API"
Depends on: TimelineEvent entity issue (#2) — must land first (entity, timeline/ package, Flyway migration for timeline_events + join tables, ADR for the new domain, CLAUDE.md package-table entry, DB diagram). This issue must not introduce its own migration.

Scope

Curator CRUD for timeline events. Backend-only — no rendered surface (curator forms live in spec issue #9). Clean layered architecture: TimelineEventController → TimelineService → TimelineEventRepository; persons/documents resolved through their services, never their repositories.

Endpoints

  • POST /api/timeline/events — create. @RequirePermission(Permission.WRITE_ALL)
  • PUT /api/timeline/events/{id} — update. @RequirePermission(Permission.WRITE_ALL)
  • DELETE /api/timeline/events/{id} — delete. @RequirePermission(Permission.WRITE_ALL)
  • GET /api/timeline/events/{id} — single event for the edit form. READ_ALL

The GET response must expose the linked personIds / documentIds (and full linked person/document entities as the model serializes) so the curator edit form (#9) can pre-populate its pickers without a second round-trip. The entity is returned directly (no response DTO), consistent with the project pattern. At family-archive scale, linked collections will typically contain 0–5 items; a payload with 50 full entities is acceptable and expected behavior, not a bug.

Data / request DTO

TimelineEventRequest — flat input DTO in timeline/:

Field Type Rules
title String Required, non-blank → 400 if missing/blank.
type EventType (enum) Required → 400 if null.
eventDate LocalDate Required → 400 if null. Server normalizes to {year}-01-01 when precision == YEAR (see D5).
precision DatePrecision (enum) Optional in the payload — server defaults to YEAR when omitted.
eventDateEnd LocalDate Only valid when precision == RANGE. See RANGE invariant below.
description String Optional. @Size(max = 5000) on the DTO. Persisted with @Column(length = 5000) on the entity (see D6).
personIds List<UUID> Optional; null ⇒ no links (no NPE). @Size(max = 50) (see D7). On update, replaces the link set.
documentIds List<UUID> Optional; null ⇒ no links (no NPE). @Size(max = 50) (see D7). On update, replaces the link set.

Bean Validation on @Size constraints produces 400 automatically — no service-layer code needed for the size caps.

RANGE invariant

  • eventDateEnd != null && precision != RANGE400 (end only meaningful for ranges).
  • precision == RANGE && eventDateEnd == null400 (a RANGE event requires a non-null end — see decision D2).
  • Resolve personIds via PersonService.getAllById(List<UUID>). Important: getAllById calls personRepository.findAllById(ids) which silently returns a shorter list for unknown IDs — it does not throw. TimelineService must check the returned list size against the input size and throw DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found") on mismatch. Do not modify PersonService.getAllById.
  • Resolve documentIds by looping DocumentService.getDocumentById(UUID) per id — there is no batch fetch on DocumentService; per-id gives free DOCUMENT_NOT_FOUND 404s and avoids a speculative batch method. Do not inject DocumentRepository into TimelineService.
  • Any unresolvable id → reject the entire create/update with 404; nothing is persisted (repository.save never called).

Service behavior

  • create(TimelineEventRequest request, UUID actorId) and update(UUID id, TimelineEventRequest request, UUID actorId).
  • Thread actorId through controller → service. The project has no JPA auditing (@EnableJpaAuditing/AuditorAware absent) — createdBy/updatedBy are set manually, mirroring DocumentService.updateDocument(..., UUID actorId) and AnnotationService. Controller obtains it via UUID actorId = requireUserId(authentication); (as DocumentController does) and passes it in. createdBy/updatedBy are UUID fields on the entity (consistent with DocumentAnnotation and other entities — not String, not an AppUser relation). create sets createdBy; update sets updatedBy.
  • @Transactional on write methods only (create/update/delete). No class-level @Transactional.
  • getEvent(UUID id) is a read → @Transactional(readOnly = true) — this is load-bearing architecture, not optional. Omitting it causes LazyInitializationException in production when the persons/documents lazy collections are accessed during serialization (ADR-022 / getDocumentDetail precedent).
  • PUT/DELETE/GET on a missing event id → DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, ...).
  • Update replaces (set semantics) the persons/documents collections from the request — not merged.

Method structure in create/update

Order the method as named private steps to keep it under 20 lines:

  1. validateRequiredFields(request) — title non-blank, type/eventDate non-null.
  2. validateRangeInvariant(request) — both RANGE directions.
  3. normalizeEventDate(request) — if precision is YEAR, set day/month to 1 (D5).
  4. resolvePersons(personIds) — getAllById + size check.
  5. resolveDocuments(documentIds) — per-id loop.
  6. Build entity and repository.save(...).

Tasks

  • TimelineEventController (POST/PUT/DELETE/GET) with @RequirePermission(Permission.WRITE_ALL) on the three writes, READ_ALL on GET; thread actorId = requireUserId(authentication) into create/update.
  • TimelineService write methods (create, update, delete) + getEvent read (with @Transactional(readOnly = true)); owns TimelineEventRepository.
  • TimelineEventRequest input DTO (flat in timeline/): title, type, eventDate, precision, eventDateEnd, description (@Size(max=5000)), personIds (@Size(max=50)), documentIds (@Size(max=50)).
  • Resolve persons via PersonService.getAllById + explicit size-comparison check → throw PERSON_NOT_FOUND on mismatch. Resolve documents by looping DocumentService.getDocumentById — no repo injection, fail-closed.
  • Set createdBy (UUID) on create / updatedBy (UUID) on update from actorId.
  • Guard-clause validation at the boundary: required title/type/eventDate; default precision to YEAR when omitted; RANGE invariant (both directions); server-normalize eventDate to {year}-01-01 when precision is YEAR.
  • Add ErrorCode.TIMELINE_EVENT_NOT_FOUND under a // --- Timeline --- section comment in ErrorCode.java. Add to frontend/src/lib/shared/errors.ts, add a case in getErrorMessage(), add i18n keys error_timeline_event_not_found in messages/{de,en,es}.json (e.g. German: "Zeitleistenereignis nicht gefunden."). Reuse existing PERSON_NOT_FOUND / DOCUMENT_NOT_FOUND for link resolution — no new codes for those.
  • Entity: @Column(length = 5000) for description. No @Version field (D4 — omit optimistic locking; add later if concurrent-edit scenarios arise).
  • npm run generate:api after the model/endpoint changes.
  • Docs (PR blockers for this issue): add docs/architecture/c4/l3-backend-timeline.puml; add TimelineEvent / EventType to docs/GLOSSARY.md. (Flyway/DB-diagram/ADR belong to entity issue #2.)
  • Note: The @DataJpaTest integration test requires #2's Flyway migration to be present in the classpath. It will fail until #2 merges. Mark with @Disabled("Depends on #2 migration") or accept CI red on this branch until #2 lands — decide before opening the PR.

Acceptance criteria

  • All four endpoints behave; the three writes are permission-gated.
  • Auth: unauthenticated call to any write endpoint → 401; READ_ALL-only user calling a write endpoint → 403; WRITE_ALL → 2xx.
  • Not found: GET/PUT/DELETE on a nonexistent event id → 404 with TIMELINE_EVENT_NOT_FOUND.
  • Required fields: blank title, or null type/eventDate400.
  • Precision default: omitting precision in the request defaults it to YEAR.
  • Date normalization: a request with precision = YEAR and any eventDate stores {year}-01-01 (not {year}-07-15 or similar).
  • RANGE invariant: non-null eventDateEnd with precision != RANGE400; precision == RANGE with null eventDateEnd400.
  • Unknown link (fail-closed): an unknown personId/documentId404, nothing persisted.
  • Update link semantics: the event's persons/documents are replaced by the request lists, not merged.
  • Audit fields: createdBy (UUID) set on create, updatedBy (UUID) set on update, from the authenticated actor.
  • Input caps: description > 5000 chars → 400; more than 50 personIds or documentIds400 (Bean Validation).
  • Linking persons/documents on create/update persists correctly.

Tests

Branch-coverage gate is 88% (backend) — the negative tests below are required, not optional. Use @Builder factories (makeTimelineEvent(...)); no setter chains, no 20-line @BeforeEach. Run targeted single files locally; full sweep to CI.

  • Controller (@WebMvcTest(TimelineEventController.class) + @Import(SecurityConfig, PermissionAspect), mock TimelineService): for each of POST/PUT/DELETE — 401 unauthenticated, 403 with READ_ALL only, 2xx with WRITE_ALL. For GET — 200 authorized, 404 missing. Include a test where personIds contains a valid and an invalid ID → assert 404 and repository.save never invoked.
  • Service (@ExtendWith(MockitoExtension.class), mock PersonService/DocumentService):
    • create persists with resolved persons/documents.
    • update replaces link collections.
    • update/delete of a missing id throws notFound(TIMELINE_EVENT_NOT_FOUND).
    • unknown personIdgetAllById returns empty list → size mismatch → throws PERSON_NOT_FOUND, repository.save never called.
    • unknown documentId → per-id loop throws DOCUMENT_NOT_FOUND, repository.save never called.
    • RANGE invariant direction 1: non-null eventDateEnd with precision != RANGE400 (separate @Test).
    • RANGE invariant direction 2: precision == RANGE with null eventDateEnd400 (separate @Test).
    • null personIds and null documentIds → no NPE, empty collections (separate @Test).
    • precision omitted → defaults to YEAR (explicit test to cover the default branch).
    • precision = YEAR with non-01-01 date → stored as {year}-01-01 (normalization test).
    • createdBy/updatedBy set from the passed actorId.
  • Integration (@DataJpaTest + Testcontainers postgres:16-alpine, never H2): round-trip — create with 2 persons + 1 doc, reload, assert the timeline_event_persons / timeline_event_documents join rows persisted and reload correctly. Uses @DataJpaTest's built-in @Transactional rollback (default; no extra annotation needed, but document in a comment). Note: requires #2's migration — mark @Disabled("Depends on #2 migration") until that issue lands.

Decisions resolved (round 1)

  • D1 — ErrorCode for a missing event: Add TIMELINE_EVENT_NOT_FOUND (over reusing a generic code). A precise 404 message and the project's structured-error discipline outweigh the cheap 4-step frontend chore; the original "no new ErrorCode" note was aspirational and is superseded.
  • D2 — RANGE end nullability: A RANGE event requires a non-null eventDateEnd. A range with no end is incoherent for a timeline marker; requiring it makes the guard and its test unambiguous.
  • D3 — Precision in the request payload: Server-defaults to YEAR when omitted. Matches the entity default and keeps the curator form forgiving.
  • D4 — @Version on TimelineEvent: Omit. Timeline events have low concurrency risk (family archive, few curators). Keeps the migration minimal; add later via a new migration if concurrent-edit scenarios arise.
  • D5 — eventDate normalization for YEAR-precision: Server normalizes. When precision == YEAR, the service sets eventDate = LocalDate.of(date.getYear(), 1, 1). This prevents silent inconsistency between events entered through different curator form implementations and makes stored data unambiguous regardless of client.
  • D6 — description max length: 5000 characters. Matches PersonUpdateDTO.bio and is ample for event narrative descriptions. Applied as @Size(max = 5000) on the DTO and @Column(length = 5000) on the entity.
  • D7 — personIds/documentIds list size cap: 50 per list. More than enough for family events (realistic max ~10 persons). Applied as @Size(max = 50) on both DTO fields; Bean Validation returns 400 automatically.

Cross-cutting recommendations from review (actorId threading, readOnly transaction on GET, fail-closed link resolution with explicit size check, per-id document loop, named private validation methods, i18n key pattern, integration test dependency note, no @Version, server-side date normalization, concrete size caps) have been folded into the relevant sections above.

**Milestone:** Zeitstrahl — Family Timeline **Spec:** `docs/superpowers/specs/2026-06-07-family-timeline-design.md` § "Assembly & API" **Depends on:** TimelineEvent entity issue (#2) — must land first (entity, `timeline/` package, Flyway migration for `timeline_events` + join tables, ADR for the new domain, `CLAUDE.md` package-table entry, DB diagram). This issue must **not** introduce its own migration. ## Scope Curator CRUD for timeline events. Backend-only — no rendered surface (curator forms live in spec issue #9). Clean layered architecture: `TimelineEventController → TimelineService → TimelineEventRepository`; persons/documents resolved through their **services**, never their repositories. ## Endpoints - `POST /api/timeline/events` — create. `@RequirePermission(Permission.WRITE_ALL)` - `PUT /api/timeline/events/{id}` — update. `@RequirePermission(Permission.WRITE_ALL)` - `DELETE /api/timeline/events/{id}` — delete. `@RequirePermission(Permission.WRITE_ALL)` - `GET /api/timeline/events/{id}` — single event for the edit form. `READ_ALL` The `GET` response must expose the linked `personIds` / `documentIds` (and full linked person/document entities as the model serializes) so the curator edit form (#9) can pre-populate its pickers without a second round-trip. The entity is returned directly (no response DTO), consistent with the project pattern. At family-archive scale, linked collections will typically contain 0–5 items; a payload with 50 full entities is acceptable and expected behavior, not a bug. ## Data / request DTO `TimelineEventRequest` — flat input DTO in `timeline/`: | Field | Type | Rules | | --- | --- | --- | | `title` | String | **Required, non-blank** → 400 if missing/blank. | | `type` | EventType (enum) | **Required** → 400 if null. | | `eventDate` | LocalDate | **Required** → 400 if null. Server normalizes to `{year}-01-01` when `precision == YEAR` (see D5). | | `precision` | DatePrecision (enum) | **Optional in the payload** — server defaults to `YEAR` when omitted. | | `eventDateEnd` | LocalDate | Only valid when `precision == RANGE`. See RANGE invariant below. | | `description` | String | Optional. `@Size(max = 5000)` on the DTO. Persisted with `@Column(length = 5000)` on the entity (see D6). | | `personIds` | List&lt;UUID&gt; | Optional; `null` ⇒ no links (no NPE). `@Size(max = 50)` (see D7). On update, **replaces** the link set. | | `documentIds` | List&lt;UUID&gt; | Optional; `null` ⇒ no links (no NPE). `@Size(max = 50)` (see D7). On update, **replaces** the link set. | Bean Validation on `@Size` constraints produces 400 automatically — no service-layer code needed for the size caps. ### RANGE invariant - `eventDateEnd != null && precision != RANGE` → **400** (end only meaningful for ranges). - `precision == RANGE && eventDateEnd == null` → **400** (a RANGE event **requires** a non-null end — see decision D2). ### Link resolution (fail-closed) - Resolve `personIds` via `PersonService.getAllById(List<UUID>)`. **Important:** `getAllById` calls `personRepository.findAllById(ids)` which silently returns a shorter list for unknown IDs — it does not throw. `TimelineService` **must** check the returned list size against the input size and throw `DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found")` on mismatch. Do not modify `PersonService.getAllById`. - Resolve `documentIds` by **looping `DocumentService.getDocumentById(UUID)` per id** — there is no batch fetch on `DocumentService`; per-id gives free `DOCUMENT_NOT_FOUND` 404s and avoids a speculative batch method. **Do not** inject `DocumentRepository` into `TimelineService`. - Any unresolvable id → reject the **entire** create/update with 404; nothing is persisted (`repository.save` never called). ## Service behavior - `create(TimelineEventRequest request, UUID actorId)` and `update(UUID id, TimelineEventRequest request, UUID actorId)`. - **Thread `actorId` through controller → service.** The project has **no JPA auditing** (`@EnableJpaAuditing`/`AuditorAware` absent) — `createdBy`/`updatedBy` are set **manually**, mirroring `DocumentService.updateDocument(..., UUID actorId)` and `AnnotationService`. Controller obtains it via `UUID actorId = requireUserId(authentication);` (as `DocumentController` does) and passes it in. `createdBy`/`updatedBy` are `UUID` fields on the entity (consistent with `DocumentAnnotation` and other entities — not `String`, not an `AppUser` relation). `create` sets `createdBy`; `update` sets `updatedBy`. - `@Transactional` on write methods only (`create`/`update`/`delete`). **No** class-level `@Transactional`. - `getEvent(UUID id)` is a read → `@Transactional(readOnly = true)` — this is **load-bearing architecture**, not optional. Omitting it causes `LazyInitializationException` in production when the `persons`/`documents` lazy collections are accessed during serialization (ADR-022 / `getDocumentDetail` precedent). - PUT/DELETE/GET on a missing event id → `DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, ...)`. - Update **replaces** (set semantics) the `persons`/`documents` collections from the request — not merged. ### Method structure in `create`/`update` Order the method as named private steps to keep it under 20 lines: 1. `validateRequiredFields(request)` — title non-blank, type/eventDate non-null. 2. `validateRangeInvariant(request)` — both RANGE directions. 3. `normalizeEventDate(request)` — if precision is YEAR, set day/month to 1 (D5). 4. `resolvePersons(personIds)` — getAllById + size check. 5. `resolveDocuments(documentIds)` — per-id loop. 6. Build entity and `repository.save(...)`. ## Tasks - [ ] `TimelineEventController` (POST/PUT/DELETE/GET) with `@RequirePermission(Permission.WRITE_ALL)` on the three writes, `READ_ALL` on GET; thread `actorId = requireUserId(authentication)` into create/update. - [ ] `TimelineService` write methods (`create`, `update`, `delete`) + `getEvent` read (with `@Transactional(readOnly = true)`); owns `TimelineEventRepository`. - [ ] `TimelineEventRequest` input DTO (flat in `timeline/`): title, type, eventDate, precision, eventDateEnd, description (`@Size(max=5000)`), personIds (`@Size(max=50)`), documentIds (`@Size(max=50)`). - [ ] Resolve persons via `PersonService.getAllById` + explicit size-comparison check → throw `PERSON_NOT_FOUND` on mismatch. Resolve documents by looping `DocumentService.getDocumentById` — no repo injection, fail-closed. - [ ] Set `createdBy` (UUID) on create / `updatedBy` (UUID) on update from `actorId`. - [ ] Guard-clause validation at the boundary: required `title`/`type`/`eventDate`; default `precision` to `YEAR` when omitted; RANGE invariant (both directions); server-normalize `eventDate` to `{year}-01-01` when precision is YEAR. - [ ] Add `ErrorCode.TIMELINE_EVENT_NOT_FOUND` under a `// --- Timeline ---` section comment in `ErrorCode.java`. Add to `frontend/src/lib/shared/errors.ts`, add a `case` in `getErrorMessage()`, add i18n keys `error_timeline_event_not_found` in `messages/{de,en,es}.json` (e.g. German: "Zeitleistenereignis nicht gefunden."). Reuse existing `PERSON_NOT_FOUND` / `DOCUMENT_NOT_FOUND` for link resolution — no new codes for those. - [ ] Entity: `@Column(length = 5000)` for `description`. No `@Version` field (D4 — omit optimistic locking; add later if concurrent-edit scenarios arise). - [ ] `npm run generate:api` after the model/endpoint changes. - [ ] Docs (PR blockers for this issue): add `docs/architecture/c4/l3-backend-timeline.puml`; add `TimelineEvent` / `EventType` to `docs/GLOSSARY.md`. (Flyway/DB-diagram/ADR belong to entity issue #2.) - [ ] **Note:** The `@DataJpaTest` integration test requires #2's Flyway migration to be present in the classpath. It will fail until #2 merges. Mark with `@Disabled("Depends on #2 migration")` or accept CI red on this branch until #2 lands — decide before opening the PR. ## Acceptance criteria - All four endpoints behave; the three writes are permission-gated. - **Auth:** unauthenticated call to any write endpoint → **401**; `READ_ALL`-only user calling a write endpoint → **403**; `WRITE_ALL` → 2xx. - **Not found:** GET/PUT/DELETE on a nonexistent event id → **404** with `TIMELINE_EVENT_NOT_FOUND`. - **Required fields:** blank `title`, or null `type`/`eventDate` → **400**. - **Precision default:** omitting `precision` in the request defaults it to `YEAR`. - **Date normalization:** a request with `precision = YEAR` and any `eventDate` stores `{year}-01-01` (not `{year}-07-15` or similar). - **RANGE invariant:** non-null `eventDateEnd` with `precision != RANGE` → **400**; `precision == RANGE` with null `eventDateEnd` → **400**. - **Unknown link (fail-closed):** an unknown `personId`/`documentId` → **404**, nothing persisted. - **Update link semantics:** the event's persons/documents are **replaced** by the request lists, not merged. - **Audit fields:** `createdBy` (UUID) set on create, `updatedBy` (UUID) set on update, from the authenticated actor. - **Input caps:** `description` > 5000 chars → **400**; more than 50 `personIds` or `documentIds` → **400** (Bean Validation). - Linking persons/documents on create/update persists correctly. ## Tests Branch-coverage gate is **88%** (backend) — the negative tests below are required, not optional. Use `@Builder` factories (`makeTimelineEvent(...)`); no setter chains, no 20-line `@BeforeEach`. Run targeted single files locally; full sweep to CI. - **Controller** (`@WebMvcTest(TimelineEventController.class)` + `@Import(SecurityConfig, PermissionAspect)`, mock `TimelineService`): for each of POST/PUT/DELETE — 401 unauthenticated, 403 with `READ_ALL` only, 2xx with `WRITE_ALL`. For GET — 200 authorized, 404 missing. Include a test where `personIds` contains a valid and an invalid ID → assert 404 and `repository.save` never invoked. - **Service** (`@ExtendWith(MockitoExtension.class)`, mock `PersonService`/`DocumentService`): - create persists with resolved persons/documents. - update **replaces** link collections. - update/delete of a missing id throws `notFound(TIMELINE_EVENT_NOT_FOUND)`. - unknown `personId` → `getAllById` returns empty list → size mismatch → throws `PERSON_NOT_FOUND`, `repository.save` never called. - unknown `documentId` → per-id loop throws `DOCUMENT_NOT_FOUND`, `repository.save` never called. - RANGE invariant direction 1: non-null `eventDateEnd` with `precision != RANGE` → **400** (separate `@Test`). - RANGE invariant direction 2: `precision == RANGE` with null `eventDateEnd` → **400** (separate `@Test`). - null `personIds` and null `documentIds` → no NPE, empty collections (separate `@Test`). - precision omitted → defaults to `YEAR` (explicit test to cover the default branch). - `precision = YEAR` with non-01-01 date → stored as `{year}-01-01` (normalization test). - `createdBy`/`updatedBy` set from the passed `actorId`. - **Integration** (`@DataJpaTest` + Testcontainers `postgres:16-alpine`, **never H2**): round-trip — create with 2 persons + 1 doc, reload, assert the `timeline_event_persons` / `timeline_event_documents` join rows persisted and reload correctly. Uses `@DataJpaTest`'s built-in `@Transactional` rollback (default; no extra annotation needed, but document in a comment). Note: requires #2's migration — mark `@Disabled("Depends on #2 migration")` until that issue lands. ## Decisions resolved (round 1) - **D1 — ErrorCode for a missing event:** **Add `TIMELINE_EVENT_NOT_FOUND`** (over reusing a generic code). A precise 404 message and the project's structured-error discipline outweigh the cheap 4-step frontend chore; the original "no new ErrorCode" note was aspirational and is superseded. - **D2 — RANGE end nullability:** **A `RANGE` event requires a non-null `eventDateEnd`.** A range with no end is incoherent for a timeline marker; requiring it makes the guard and its test unambiguous. - **D3 — Precision in the request payload:** **Server-defaults to `YEAR` when omitted.** Matches the entity default and keeps the curator form forgiving. - **D4 — `@Version` on `TimelineEvent`:** **Omit.** Timeline events have low concurrency risk (family archive, few curators). Keeps the migration minimal; add later via a new migration if concurrent-edit scenarios arise. - **D5 — `eventDate` normalization for `YEAR`-precision:** **Server normalizes.** When `precision == YEAR`, the service sets `eventDate = LocalDate.of(date.getYear(), 1, 1)`. This prevents silent inconsistency between events entered through different curator form implementations and makes stored data unambiguous regardless of client. - **D6 — `description` max length:** **5000 characters.** Matches `PersonUpdateDTO.bio` and is ample for event narrative descriptions. Applied as `@Size(max = 5000)` on the DTO and `@Column(length = 5000)` on the entity. - **D7 — `personIds`/`documentIds` list size cap:** **50 per list.** More than enough for family events (realistic max ~10 persons). Applied as `@Size(max = 50)` on both DTO fields; Bean Validation returns 400 automatically. _Cross-cutting recommendations from review (actorId threading, readOnly transaction on GET, fail-closed link resolution with explicit size check, per-id document loop, named private validation methods, i18n key pattern, integration test dependency note, no @Version, server-side date normalization, concrete size caps) have been folded into the relevant sections above._
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-07 19:28:51 +02:00
marcel added the P2-mediumfeature labels 2026-06-07 19:29:59 +02:00
Author
Owner

🏛️ Markus Keller — Application Architect

Observations

The spec is now very precise and the Round 1 decisions have been folded in cleanly. From an architecture standpoint the remaining risks are documentation completeness and one structural ambiguity in the getEvent read method.

1. getEvent transaction annotation contradicts the project's read convention
The issue says getEvent must be @Transactional(readOnly = true) because lazy collections need the session open during serialisation. That is correct for this case. But the issue also references the rule "@Transactional on write methods only" as if it's a global project rule. Clarification for the implementer: getDocumentById is also @Transactional(readOnly = true) — the real rule is "write methods get @Transactional; reads that traverse lazy associations get @Transactional(readOnly = true); simple reads with no lazy traversal get no annotation." The issue body is clear on this point but the "No annotation — reads do not need transaction overhead" sidebar in CLAUDE.md is slightly misleading for this case. Not a blocker — but worth noting in the ADR or a comment on the method.

2. Missing ADR — the spec says one "may" be warranted; the issue body doesn't require it
The design spec says: "An ADR may be warranted for the new timeline/ domain + entity." The issue task list asks for a C4 diagram but is silent on the ADR. Given this is the first concrete backend issue in a new package (timeline/), writing ADR-035 before implementation starts is consistent with the project's "write the ADR before writing the code" discipline. The highest existing ADR is ADR-034. The C4 diagram requirement (l3-backend-timeline.puml) is already in the task list — good.

3. DB diagram update requirement is not in the task list
Per Markus's review table: any new Flyway migration adding tables (which this issue depends on, though it doesn't introduce the migration itself) must update docs/architecture/db/db-orm.puml and docs/architecture/db/db-relationships.puml. Those updates belong to issue #2 (the entity migration), not here. Confirmed: the task list correctly defers that to #2. Nothing to fix — just ensuring the implementer doesn't accidentally think those are out-of-scope entirely.

4. CLAUDE.md package table entry is required
The task list calls for a CLAUDE.md package-table entry under issue #2. This issue creates TimelineEventController, TimelineService, and TimelineEventRepository — which means the package entry is needed at or before this PR. Cross-check: the task list says "Note: this issue must not introduce its own migration" — but it does introduce the controller/service/repository in the timeline/ package. The CLAUDE.md entry belongs to whichever PR first creates code in that package.

Recommendations

  • Add ADR-035 before implementation starts. Minimal content: the decision to add a timeline/ domain package, why it is separate from geschichte/, and the layering rule (owns TimelineEventRepository, reaches Person/Document through their services). Two paragraphs is enough.
  • Confirm CLAUDE.md package-table entry lands in this PR (or #2's PR, whichever comes first), not deferred past merge.
  • Add a comment on getEvent explaining why it is @Transactional(readOnly = true) — mirrors the pattern on getDocumentById (see DocumentService:1001) and prevents future refactorers from stripping it.
  • The "no migration in this issue" constraint is architecturally sound and must stay — the integration test @Disabled note handles the dependency cleanly.

Open Decisions (omit this section entirely if none)

None — all architectural decisions are resolved.

## 🏛️ Markus Keller — Application Architect ### Observations The spec is now very precise and the Round 1 decisions have been folded in cleanly. From an architecture standpoint the remaining risks are documentation completeness and one structural ambiguity in the `getEvent` read method. **1. `getEvent` transaction annotation contradicts the project's read convention** The issue says `getEvent` must be `@Transactional(readOnly = true)` because lazy collections need the session open during serialisation. That is correct for this case. But the issue also references the rule "`@Transactional` on write methods only" as if it's a global project rule. Clarification for the implementer: `getDocumentById` is also `@Transactional(readOnly = true)` — the real rule is "write methods get `@Transactional`; reads that traverse lazy associations get `@Transactional(readOnly = true)`; simple reads with no lazy traversal get no annotation." The issue body is clear on this point but the "No annotation — reads do not need transaction overhead" sidebar in CLAUDE.md is slightly misleading for this case. Not a blocker — but worth noting in the ADR or a comment on the method. **2. Missing ADR — the spec says one "may" be warranted; the issue body doesn't require it** The design spec says: *"An ADR may be warranted for the new `timeline/` domain + entity."* The issue task list asks for a C4 diagram but is silent on the ADR. Given this is the first concrete backend issue in a new package (`timeline/`), writing ADR-035 before implementation starts is consistent with the project's "write the ADR before writing the code" discipline. The highest existing ADR is ADR-034. The C4 diagram requirement (`l3-backend-timeline.puml`) is already in the task list — good. **3. DB diagram update requirement is not in the task list** Per Markus's review table: any new Flyway migration adding tables (which this issue depends on, though it doesn't introduce the migration itself) must update `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml`. Those updates belong to issue #2 (the entity migration), not here. Confirmed: the task list correctly defers that to #2. Nothing to fix — just ensuring the implementer doesn't accidentally think those are out-of-scope entirely. **4. `CLAUDE.md` package table entry is required** The task list calls for a `CLAUDE.md` package-table entry under issue #2. This issue creates `TimelineEventController`, `TimelineService`, and `TimelineEventRepository` — which means the package entry is needed at or before this PR. Cross-check: the task list says "Note: this issue must not introduce its own migration" — but it does introduce the controller/service/repository in the `timeline/` package. The `CLAUDE.md` entry belongs to whichever PR first creates code in that package. ### Recommendations - **Add ADR-035** before implementation starts. Minimal content: the decision to add a `timeline/` domain package, why it is separate from `geschichte/`, and the layering rule (owns `TimelineEventRepository`, reaches Person/Document through their services). Two paragraphs is enough. - **Confirm `CLAUDE.md` package-table entry** lands in this PR (or #2's PR, whichever comes first), not deferred past merge. - **Add a comment on `getEvent`** explaining why it is `@Transactional(readOnly = true)` — mirrors the pattern on `getDocumentById` (see `DocumentService:1001`) and prevents future refactorers from stripping it. - The "no migration in this issue" constraint is architecturally sound and must stay — the integration test `@Disabled` note handles the dependency cleanly. ### Open Decisions _(omit this section entirely if none)_ None — all architectural decisions are resolved.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

The spec is tight and the Round 1 recommendations (actorId threading, readOnly transaction, fail-closed resolution, named private methods, @Disabled note) have all been folded in. From a clean-code and implementation standpoint, a few concrete gaps remain before this is ready to code.

1. validateRequiredFields duplicates Bean Validation work
The spec mandates Bean Validation (@NotBlank on title, @NotNull on type/eventDate) and also a named private validateRequiredFields(request) step. If Bean Validation is wired correctly with @Valid on the controller parameter, the service-layer validateRequiredFields step is dead code — Bean Validation fires first at the controller boundary and the service never sees an invalid request. The spec should clarify: either (a) the service trusts Bean Validation and the validateRequiredFields private method is a fallback guard (in which case it's belt-and-suspenders), or (b) the service must guard even for non-HTTP callers. In the current architecture, services are always called from controllers via HTTP, so (a) is fine but it needs to be explicit.

2. The resolveDocuments loop creates a semantically different failure mode from resolvePersons
For persons: the spec uses getAllById (batch) + explicit size check — one round trip, one failure point. For documents: the spec mandates a per-id loop calling DocumentService.getDocumentById(UUID). This is asymmetric: a request with 50 documentIds makes 50 separate DB round trips. At family-archive scale (50 docs max) this is acceptable, but the implementer should know that getDocumentById is @Transactional(readOnly = true) internally — calling it 50 times inside the parent @Transactional write method will participate in the outer transaction correctly (Spring's default REQUIRED propagation). No bug, but worth a brief comment in the implementation.

3. The methodStructure order exposes an edge case in the update path
The spec's 6-step order for create/update is: validate → range-invariant → normalize → resolvePersons → resolveDocuments → save. In the update path, step 6 (save) replaces the existing persons/documents sets. The spec says "replaces (set semantics)." The implementer must clear and re-add collection members rather than reassigning a new Set to the field — Hibernate doesn't track a replaced collection reference. Concrete code: event.getPersons().clear(); event.getPersons().addAll(resolvedPersons); not event.setPersons(new HashSet<>(resolvedPersons)). This is a common Hibernate pitfall that can cause orphan rows or constraint violations depending on the join-table cascade config on the entity (which comes from issue #2).

4. Missing @NotBlank, @NotNull annotations on the DTO
The task list says @Size(max=5000) and @Size(max=50) are needed. It does not explicitly list @NotBlank (title) and @NotNull (type, eventDate). These must be in the DTO for Bean Validation to fire. The issue's acceptance criteria require 400 on blank/null — Bean Validation is how that happens. Add them to the DTO spec.

5. npm run generate:api is listed but the spec is backend-only
The issue correctly calls for regenerating types after model/endpoint changes. Since this is a backend-only issue, the only frontend-touching step is regeneration — no components. Confirm in the PR description that the developer ran npm run generate:api and committed the updated types.

Recommendations

  • Clarify validateRequiredFields intent: if Bean Validation is the primary guard (correct choice), rename the private method to assertRequiredFieldsPresent and document it as a defensive guard for non-HTTP call paths, or remove it entirely and rely on @Valid.
  • Add a comment on the resolveDocuments loop noting that getDocumentById participates in the outer @Transactional context — prevents future "why are we making 50 queries?" confusion.
  • Use clear()+addAll() on Hibernate collections in the update path — never replace the collection reference.
  • Add @NotBlank to title and @NotNull to type/eventDate in the DTO. These are required for the 400 acceptance criterion to pass via Bean Validation.
  • The named private methods (validateRangeInvariant, normalizeEventDate, resolvePersons, resolveDocuments) are clean — stick to them. Keep each under 10 lines.

Open Decisions (omit this section entirely if none)

None — all code-style decisions are resolved.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations The spec is tight and the Round 1 recommendations (actorId threading, readOnly transaction, fail-closed resolution, named private methods, `@Disabled` note) have all been folded in. From a clean-code and implementation standpoint, a few concrete gaps remain before this is ready to code. **1. `validateRequiredFields` duplicates Bean Validation work** The spec mandates Bean Validation (`@NotBlank` on title, `@NotNull` on type/eventDate) and also a named private `validateRequiredFields(request)` step. If Bean Validation is wired correctly with `@Valid` on the controller parameter, the service-layer `validateRequiredFields` step is dead code — Bean Validation fires first at the controller boundary and the service never sees an invalid request. The spec should clarify: either (a) the service trusts Bean Validation and the `validateRequiredFields` private method is a fallback guard (in which case it's belt-and-suspenders), or (b) the service must guard even for non-HTTP callers. In the current architecture, services are always called from controllers via HTTP, so (a) is fine but it needs to be explicit. **2. The `resolveDocuments` loop creates a semantically different failure mode from `resolvePersons`** For persons: the spec uses `getAllById` (batch) + explicit size check — one round trip, one failure point. For documents: the spec mandates a per-id loop calling `DocumentService.getDocumentById(UUID)`. This is asymmetric: a request with 50 documentIds makes 50 separate DB round trips. At family-archive scale (50 docs max) this is acceptable, but the implementer should know that `getDocumentById` is `@Transactional(readOnly = true)` internally — calling it 50 times inside the parent `@Transactional` write method will participate in the outer transaction correctly (Spring's default `REQUIRED` propagation). No bug, but worth a brief comment in the implementation. **3. The `methodStructure` order exposes an edge case in the `update` path** The spec's 6-step order for `create`/`update` is: validate → range-invariant → normalize → resolvePersons → resolveDocuments → save. In the `update` path, step 6 (save) replaces the existing `persons`/`documents` sets. The spec says "replaces (set semantics)." The implementer must clear and re-add collection members rather than reassigning a new `Set` to the field — Hibernate doesn't track a replaced collection reference. Concrete code: `event.getPersons().clear(); event.getPersons().addAll(resolvedPersons);` not `event.setPersons(new HashSet<>(resolvedPersons))`. This is a common Hibernate pitfall that can cause orphan rows or constraint violations depending on the join-table cascade config on the entity (which comes from issue #2). **4. Missing `@NotBlank`, `@NotNull` annotations on the DTO** The task list says `@Size(max=5000)` and `@Size(max=50)` are needed. It does not explicitly list `@NotBlank` (title) and `@NotNull` (type, eventDate). These must be in the DTO for Bean Validation to fire. The issue's acceptance criteria require 400 on blank/null — Bean Validation is how that happens. Add them to the DTO spec. **5. `npm run generate:api` is listed but the spec is backend-only** The issue correctly calls for regenerating types after model/endpoint changes. Since this is a backend-only issue, the only frontend-touching step is regeneration — no components. Confirm in the PR description that the developer ran `npm run generate:api` and committed the updated types. ### Recommendations - **Clarify `validateRequiredFields`** intent: if Bean Validation is the primary guard (correct choice), rename the private method to `assertRequiredFieldsPresent` and document it as a defensive guard for non-HTTP call paths, or remove it entirely and rely on `@Valid`. - **Add a comment on the `resolveDocuments` loop** noting that `getDocumentById` participates in the outer `@Transactional` context — prevents future "why are we making 50 queries?" confusion. - **Use `clear()`+`addAll()` on Hibernate collections** in the update path — never replace the collection reference. - **Add `@NotBlank` to `title` and `@NotNull` to `type`/`eventDate`** in the DTO. These are required for the 400 acceptance criterion to pass via Bean Validation. - The named private methods (`validateRangeInvariant`, `normalizeEventDate`, `resolvePersons`, `resolveDocuments`) are clean — stick to them. Keep each under 10 lines. ### Open Decisions _(omit this section entirely if none)_ None — all code-style decisions are resolved.
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer

Observations

Round 1 folded in the key security-relevant decisions (permission gating on all three writes, actorId threading from the authenticated principal, fail-closed link resolution). The spec is now security-sound at the model level. My Round 2 checks focus on what the spec doesn't say explicitly but the implementation must get right.

1. READ_ALL on GET /api/timeline/events/{id} — confirmed correct, but annotate explicitly
The issue says GET requires READ_ALL. Checking the existing controller patterns: DocumentController does not annotate its GET endpoints with @RequirePermission because the project's SecurityConfig requires authentication globally (.anyRequest().authenticated()). READ_ALL is effectively the baseline authenticated permission. The spec is correct to call it out, but the implementation question is: does TimelineEventController.getEvent need an explicit @RequirePermission(Permission.READ_ALL) annotation, or does global authentication (anyRequest().authenticated()) cover it? Looking at DocumentController.getDocument — no annotation, relies on global auth. The issue spec says READ_ALL is required but doesn't say whether that means an explicit annotation or relies on the global rule. For consistency with existing controllers, rely on the global auth rule (no @RequirePermission on GET). Adding @RequirePermission(READ_ALL) would be redundant but harmless. Decide one way; either is defensible.

2. personIds and documentIds are user-controlled UUID lists — no authorization check on the linked entities
The spec resolves persons/documents by ID and fails closed if any ID is unknown. However, it does not check whether the authenticated user has read access to the linked person or document. In this family archive, all authenticated users have READ_ALL (by design — global auth requirement + READ_ALL baseline), so linking a person/document you can read is always allowed. This is correct for the current permission model. If the permission model ever gains per-document access control, link resolution would need authorization. Not a current risk — noting for future-proofing.

3. description field: 5000 chars, no HTML sanitization — correct for this context
The description is stored and returned as plain text. The frontend rendering should use textContent (or Svelte's default text interpolation {description}) not {@html description}. The spec doesn't mention this but the frontend issue (#9 cursor form) should be reminded. Not a backend concern for this issue.

4. createdBy/updatedBy are set from requireUserId(authentication) — correct and consistent
This matches DocumentController.updateDocument exactly: UUID actorId = requireUserId(authentication) → passed to service → set on entity. The only risk is forgetting to pass actorId on the create call. The controller test suite (spec: @WebMvcTest with mocked TimelineService) should assert that the service create/update methods are called with the correct actorId — not just that the endpoint returns 2xx.

5. No @Version (optimistic locking omitted — D4)
Decision D4 is correct for this domain. The only security nuance: without optimistic locking, concurrent PUT requests on the same event id will silently last-write-wins. For a family archive with few curators editing the same event simultaneously, this is acceptable. The spec correctly calls it out.

Recommendations

  • Decide explicitly in the controller whether GET uses @RequirePermission(READ_ALL) or relies on the global auth filter. Document the choice with a one-line comment. Either is correct; consistency with DocumentController (no annotation on GET) is the tie-breaker.
  • Add a controller test that verifies actorId is passed through to the service — not just that the endpoint returns 200. Use ArgumentCaptor on the mocked TimelineService.
  • Note for issue #9 (curator form): the description field must be rendered with text interpolation, not {@html}, since no sanitization is applied at the backend.
  • No new ErrorCodes expose attack surface. The 4-step i18n chore for TIMELINE_EVENT_NOT_FOUND is correctly scoped.

Open Decisions (omit this section entirely if none)

None — all security-relevant decisions are resolved.

## 🔒 Nora "NullX" Steiner — Security Engineer ### Observations Round 1 folded in the key security-relevant decisions (permission gating on all three writes, actorId threading from the authenticated principal, fail-closed link resolution). The spec is now security-sound at the model level. My Round 2 checks focus on what the spec doesn't say explicitly but the implementation must get right. **1. `READ_ALL` on `GET /api/timeline/events/{id}` — confirmed correct, but annotate explicitly** The issue says GET requires `READ_ALL`. Checking the existing controller patterns: `DocumentController` does not annotate its GET endpoints with `@RequirePermission` because the project's `SecurityConfig` requires authentication globally (`.anyRequest().authenticated()`). `READ_ALL` is effectively the baseline authenticated permission. The spec is correct to call it out, but the implementation question is: does `TimelineEventController.getEvent` need an explicit `@RequirePermission(Permission.READ_ALL)` annotation, or does global authentication (`anyRequest().authenticated()`) cover it? Looking at `DocumentController.getDocument` — no annotation, relies on global auth. The issue spec says `READ_ALL` is required but doesn't say whether that means an explicit annotation or relies on the global rule. For consistency with existing controllers, rely on the global auth rule (no `@RequirePermission` on GET). Adding `@RequirePermission(READ_ALL)` would be redundant but harmless. Decide one way; either is defensible. **2. `personIds` and `documentIds` are user-controlled UUID lists — no authorization check on the linked entities** The spec resolves persons/documents by ID and fails closed if any ID is unknown. However, it does not check whether the authenticated user has read access to the linked person or document. In this family archive, all authenticated users have `READ_ALL` (by design — global auth requirement + `READ_ALL` baseline), so linking a person/document you can read is always allowed. This is correct for the current permission model. If the permission model ever gains per-document access control, link resolution would need authorization. Not a current risk — noting for future-proofing. **3. `description` field: 5000 chars, no HTML sanitization — correct for this context** The `description` is stored and returned as plain text. The frontend rendering should use `textContent` (or Svelte's default text interpolation `{description}`) not `{@html description}`. The spec doesn't mention this but the frontend issue (#9 cursor form) should be reminded. Not a backend concern for this issue. **4. `createdBy`/`updatedBy` are set from `requireUserId(authentication)` — correct and consistent** This matches `DocumentController.updateDocument` exactly: `UUID actorId = requireUserId(authentication)` → passed to service → set on entity. The only risk is forgetting to pass `actorId` on the `create` call. The controller test suite (spec: `@WebMvcTest` with mocked `TimelineService`) should assert that the service `create`/`update` methods are called with the correct `actorId` — not just that the endpoint returns 2xx. **5. No `@Version` (optimistic locking omitted — D4)** Decision D4 is correct for this domain. The only security nuance: without optimistic locking, concurrent `PUT` requests on the same event id will silently last-write-wins. For a family archive with few curators editing the same event simultaneously, this is acceptable. The spec correctly calls it out. ### Recommendations - **Decide explicitly** in the controller whether GET uses `@RequirePermission(READ_ALL)` or relies on the global auth filter. Document the choice with a one-line comment. Either is correct; consistency with `DocumentController` (no annotation on GET) is the tie-breaker. - **Add a controller test** that verifies `actorId` is passed through to the service — not just that the endpoint returns 200. Use `ArgumentCaptor` on the mocked `TimelineService`. - **Note for issue #9 (curator form)**: the `description` field must be rendered with text interpolation, not `{@html}`, since no sanitization is applied at the backend. - No new ErrorCodes expose attack surface. The 4-step i18n chore for `TIMELINE_EVENT_NOT_FOUND` is correctly scoped. ### Open Decisions _(omit this section entirely if none)_ None — all security-relevant decisions are resolved.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

The test section in this issue is the most prescriptive I've seen in this project — every test is named with its scenario, layered correctly across the three test types, and the @Disabled dependency caveat is explicit. Round 1 recommendations have been incorporated. Remaining gaps are about coverage completeness and a possible testing pattern trap.

1. The @DataJpaTest integration test has a critical dependency issue — the @Disabled note is insufficient by itself
The spec says: "Mark with @Disabled(\"Depends on #2 migration\") until #2 lands." This is correct. But @DataJpaTest by default runs Flyway and expects the migration history to be consistent. Without #2's migration, the @DataJpaTest will not just fail — it may fail at Spring context loading (Flyway validation error) before the test even runs. The @Disabled annotation won't prevent context load. The safe pattern is @DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) + Testcontainers, with Flyway disabled or the migration absent. Recommend: add a comment in the test class noting that until #2's migration file is present in src/main/resources/db/migration/, the test class will fail at Flyway startup even when @Disabled is on individual tests. The whole class may need to be @Disabled or wrapped in a conditional.

2. Controller test: the spec says "include a test where personIds contains a valid and an invalid ID → assert 404 and repository.save never invoked"
This is good. But "repository.save never invoked" in a @WebMvcTest tests the service mock, not the repository. The correct assertion is verify(timelineService, never()).create(...) (or equivalently verify the mock method was not called). In a @WebMvcTest with mocked TimelineService, the repository is not in context at all. Clarify in the implementation: the controller test verifies that when the service throws PERSON_NOT_FOUND, the controller returns 404 — the "save never invoked" guarantee is in the service unit test, not the controller test.

3. Missing test: update with empty personIds / empty documentIds (not null, but empty list)
The spec tests null → no NPE. But personIds = [] (empty list, not null) is a distinct case: it means "clear all links." The resolvePersons size-check logic: getAllById([]) returns []; input size is 0; returned size is 0; size check passes; result is empty set. This should work correctly but it's not explicitly tested. Add: update with empty personIds replaces link set with empty set.

4. Test ordering in service test — the "RANGE invariant direction 1 and 2" tests are listed as separate @Test methods
Good — the spec correctly says "separate @Test" for each direction. This is the right approach (one behavior per test). Confirm that each test uses a minimal TimelineEventRequest that only varies the failing field — not a mega-request that happens to also trigger the other invariant.

5. 88% branch coverage gate — the test list is comprehensive but may fall short on the normalizeEventDate branches
The normalization logic has at least two branches: precision == YEAR (normalize) and precision != YEAR (don't normalize). The spec explicitly includes "precision = YEAR with non-01-01 date → stored as {year}-01-01" test. The spec also has "precision omitted → defaults to YEAR." Confirm: does "defaults to YEAR then normalizes" get covered by combining those two tests? Yes — the default-to-YEAR test plus the normalization test together cover both branches. Good.

Recommendations

  • Clarify @Disabled scope: if the @DataJpaTest class fails at context load (not at test run), @Disabled on individual tests won't help. Consider @Disabled at the class level, or suppress Flyway migration validation for the class with @AutoConfigureTestDatabase(replace = NONE) + a spring.flyway.enabled=false override until #2 lands.
  • Move "save never invoked" assertion to the service unit test — it belongs there. The controller test asserts HTTP 404 status when the service throws the right exception.
  • Add test: update with empty personIds list clears all linked persons — distinct from the null case.
  • The factory makeTimelineEvent(...) should be the primary setup vehicle. Confirm all 3 test classes share the same factory pattern (or a shared test util class) rather than each class having its own builder chain.

Open Decisions (omit this section entirely if none)

None.

## 🧪 Sara Holt — QA Engineer ### Observations The test section in this issue is the most prescriptive I've seen in this project — every test is named with its scenario, layered correctly across the three test types, and the `@Disabled` dependency caveat is explicit. Round 1 recommendations have been incorporated. Remaining gaps are about coverage completeness and a possible testing pattern trap. **1. The `@DataJpaTest` integration test has a critical dependency issue — the `@Disabled` note is insufficient by itself** The spec says: "Mark with `@Disabled(\"Depends on #2 migration\")` until #2 lands." This is correct. But `@DataJpaTest` by default runs Flyway and expects the migration history to be consistent. Without #2's migration, the `@DataJpaTest` will not just fail — it may fail at Spring context loading (Flyway validation error) before the test even runs. The `@Disabled` annotation won't prevent context load. The safe pattern is `@DataJpaTest` + `@AutoConfigureTestDatabase(replace = NONE)` + Testcontainers, with Flyway disabled or the migration absent. Recommend: add a comment in the test class noting that until #2's migration file is present in `src/main/resources/db/migration/`, the test class will fail at Flyway startup even when `@Disabled` is on individual tests. The whole class may need to be `@Disabled` or wrapped in a conditional. **2. Controller test: the spec says "include a test where `personIds` contains a valid and an invalid ID → assert 404 and `repository.save` never invoked"** This is good. But "repository.save never invoked" in a `@WebMvcTest` tests the service mock, not the repository. The correct assertion is `verify(timelineService, never()).create(...)` (or equivalently verify the mock method was not called). In a `@WebMvcTest` with mocked `TimelineService`, the repository is not in context at all. Clarify in the implementation: the controller test verifies that when the service throws `PERSON_NOT_FOUND`, the controller returns 404 — the "save never invoked" guarantee is in the **service unit test**, not the controller test. **3. Missing test: update with empty `personIds` / empty `documentIds` (not null, but empty list)** The spec tests `null` → no NPE. But `personIds = []` (empty list, not null) is a distinct case: it means "clear all links." The `resolvePersons` size-check logic: `getAllById([])` returns `[]`; input size is 0; returned size is 0; size check passes; result is empty set. This should work correctly but it's not explicitly tested. Add: `update with empty personIds replaces link set with empty set`. **4. Test ordering in service test — the "RANGE invariant direction 1 and 2" tests are listed as separate `@Test` methods** Good — the spec correctly says "separate `@Test`" for each direction. This is the right approach (one behavior per test). Confirm that each test uses a minimal `TimelineEventRequest` that only varies the failing field — not a mega-request that happens to also trigger the other invariant. **5. 88% branch coverage gate — the test list is comprehensive but may fall short on the `normalizeEventDate` branches** The normalization logic has at least two branches: `precision == YEAR` (normalize) and `precision != YEAR` (don't normalize). The spec explicitly includes "precision = YEAR with non-01-01 date → stored as `{year}-01-01`" test. The spec also has "precision omitted → defaults to YEAR." Confirm: does "defaults to YEAR then normalizes" get covered by combining those two tests? Yes — the default-to-YEAR test plus the normalization test together cover both branches. Good. ### Recommendations - **Clarify `@Disabled` scope**: if the `@DataJpaTest` class fails at context load (not at test run), `@Disabled` on individual tests won't help. Consider `@Disabled` at the class level, or suppress Flyway migration validation for the class with `@AutoConfigureTestDatabase(replace = NONE)` + a `spring.flyway.enabled=false` override until #2 lands. - **Move "save never invoked" assertion to the service unit test** — it belongs there. The controller test asserts HTTP 404 status when the service throws the right exception. - **Add test**: `update with empty personIds list clears all linked persons` — distinct from the null case. - The factory `makeTimelineEvent(...)` should be the primary setup vehicle. Confirm all 3 test classes share the same factory pattern (or a shared test util class) rather than each class having its own builder chain. ### Open Decisions _(omit this section entirely if none)_ None.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Observations

This issue is a pure backend application issue with no infrastructure changes. No new Docker services, no new compose entries, no CI workflow changes. My review is correspondingly brief.

1. CI impact of the @Disabled integration test
The issue notes that the @DataJpaTest integration test requires #2's Flyway migration and should be marked @Disabled. The coverage gate is 88% branch coverage. A disabled test class contributes 0% to coverage — if this class is fully disabled, the branches it would have covered are unexercised. This means the 88% gate may require the remaining controller + service tests to cover those branches instead. The spec's test list is comprehensive enough that coverage should still be met from the service unit tests and controller @WebMvcTest tests, but the implementer should run ./mvnw clean verify on this branch (without #2 present) to confirm the gate passes before opening the PR.

2. No new env vars or secrets
This issue introduces no new configuration values, environment variables, or secrets. Nothing to add to .env.example or the docker-compose.yml.

3. npm run generate:api in CI
The issue correctly calls for regenerating types. In CI, the TypeScript type regeneration step requires the backend to be running (--spring.profiles.active=dev). This is not a new concern — it's an existing pattern — but the PR checklist should confirm types were regenerated locally and committed. The CI pipeline doesn't regenerate types automatically (it runs pre-committed types).

Recommendations

  • Run ./mvnw clean verify locally before opening the PR to confirm the 88% branch gate passes with the integration test @Disabled.
  • No infrastructure changes needed. No compose or CI changes needed.

No concerns from my angle beyond the coverage gate note above.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Observations This issue is a pure backend application issue with no infrastructure changes. No new Docker services, no new compose entries, no CI workflow changes. My review is correspondingly brief. **1. CI impact of the `@Disabled` integration test** The issue notes that the `@DataJpaTest` integration test requires #2's Flyway migration and should be marked `@Disabled`. The coverage gate is 88% branch coverage. A disabled test class contributes 0% to coverage — if this class is fully disabled, the branches it would have covered are unexercised. This means the 88% gate may require the remaining controller + service tests to cover those branches instead. The spec's test list is comprehensive enough that coverage should still be met from the service unit tests and controller `@WebMvcTest` tests, but the implementer should run `./mvnw clean verify` on this branch (without #2 present) to confirm the gate passes before opening the PR. **2. No new env vars or secrets** This issue introduces no new configuration values, environment variables, or secrets. Nothing to add to `.env.example` or the `docker-compose.yml`. **3. `npm run generate:api` in CI** The issue correctly calls for regenerating types. In CI, the TypeScript type regeneration step requires the backend to be running (`--spring.profiles.active=dev`). This is not a new concern — it's an existing pattern — but the PR checklist should confirm types were regenerated locally and committed. The CI pipeline doesn't regenerate types automatically (it runs pre-committed types). ### Recommendations - **Run `./mvnw clean verify` locally** before opening the PR to confirm the 88% branch gate passes with the integration test `@Disabled`. - No infrastructure changes needed. No compose or CI changes needed. No concerns from my angle beyond the coverage gate note above.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

The spec is now exceptionally precise for a backend-only issue — all D1–D7 decisions are resolved, acceptance criteria are testable, edge cases are enumerated, and the dependency chain is explicit. This is a well-structured issue. My role in Round 2 is to look for any remaining untestable, ambiguous, or missing requirements that the implementation team could interpret differently.

1. The createdBy / updatedBy update semantics are partially underspecified
The spec says: "create sets createdBy; update sets updatedBy." It does not say whether update also preserves createdBy (i.e., the original creator's UUID is never overwritten). This is almost certainly the intent (standard audit pattern), but the acceptance criteria don't include: "after an update, createdBy still holds the original actor." Add this as an acceptance criterion or test, otherwise the implementer could set both createdBy and updatedBy on every update.

2. The acceptance criterion for "Precision default" is testable but the test scenario is too narrow
"Omitting precision in the request defaults it to YEAR." This is verified by the service unit test. However, the acceptance criterion doesn't specify what happens on GET /api/timeline/events/{id} — does the response reflect precision = YEAR (i.e., the default is persisted, not just applied transiently)? The entity default YEAR should be stored, not just computed. Confirm: the DTO has precision as Optional/nullable, and the service sets the entity's precision field to YEAR before calling save. The round-trip integration test should verify this.

3. Missing acceptance criterion: eventDateEnd is null after create with precision != RANGE
The spec guards against eventDateEnd != null && precision != RANGE → 400. But there's no criterion stating that after a successful create with precision = YEAR, eventDateEnd is null in the stored entity and returned in GET. This is implied but not explicit. Minor — add one sentence.

4. The @Size(max=50) on personIds/documentIds — what does the API return when violated?
Bean Validation on @Size returns a generic 400 with a validation error body. The issue says "Bean Validation on @Size constraints produces 400 automatically — no service-layer code needed." Confirmed. But the acceptance criterion says more than 50 personIds → 400. The error response body will be Spring's default validation error format (not a DomainException structured error with ErrorCode). This is the existing project behavior (consistent with @Size(max=5000) on description). Fine — just noting it's a different error shape than DomainException errors.

5. No acceptance criterion for the response body shape of GET /api/timeline/events/{id}
The spec says the entity is returned directly (no response DTO), consistent with the project pattern, and that personIds/documentIds (full linked entities) must be exposed. The acceptance criteria don't include: "GET response includes the linked person entities" or "GET response includes the linked document entities." Add this, otherwise the implementer might return a minimal entity without initializing lazy collections (which would surface as empty arrays or a LazyInitializationException if @Transactional(readOnly = true) is missing).

Recommendations

  • Add acceptance criterion: "After update, createdBy retains the original creator UUID; only updatedBy changes."
  • Add acceptance criterion: "GET /api/timeline/events/{id} response includes the full linked person and document entities (not just their IDs), enabling the curator edit form to pre-populate pickers without a second round-trip."
  • Add acceptance criterion: "precision defaulted to YEAR is persisted and returned in the GET response."
  • Add acceptance criterion: "eventDateEnd is null in the GET response when precision != RANGE."
  • These are all implicit in the spec but making them explicit produces unambiguous test cases.

Open Decisions (omit this section entirely if none)

None — all requirements decisions are resolved. These are documentation clarifications, not new decisions.

## 📋 Elicit — Requirements Engineer ### Observations The spec is now exceptionally precise for a backend-only issue — all D1–D7 decisions are resolved, acceptance criteria are testable, edge cases are enumerated, and the dependency chain is explicit. This is a well-structured issue. My role in Round 2 is to look for any remaining untestable, ambiguous, or missing requirements that the implementation team could interpret differently. **1. The `createdBy` / `updatedBy` update semantics are partially underspecified** The spec says: "`create` sets `createdBy`; `update` sets `updatedBy`." It does not say whether `update` also preserves `createdBy` (i.e., the original creator's UUID is never overwritten). This is almost certainly the intent (standard audit pattern), but the acceptance criteria don't include: "after an update, `createdBy` still holds the original actor." Add this as an acceptance criterion or test, otherwise the implementer could set both `createdBy` and `updatedBy` on every update. **2. The acceptance criterion for "Precision default" is testable but the test scenario is too narrow** "Omitting `precision` in the request defaults it to `YEAR`." This is verified by the service unit test. However, the acceptance criterion doesn't specify what happens on `GET /api/timeline/events/{id}` — does the response reflect `precision = YEAR` (i.e., the default is persisted, not just applied transiently)? The entity default `YEAR` should be stored, not just computed. Confirm: the DTO has `precision` as `Optional`/nullable, and the service sets the entity's `precision` field to `YEAR` before calling `save`. The round-trip integration test should verify this. **3. Missing acceptance criterion: `eventDateEnd` is `null` after create with `precision != RANGE`** The spec guards against `eventDateEnd != null && precision != RANGE` → 400. But there's no criterion stating that after a successful create with `precision = YEAR`, `eventDateEnd` is `null` in the stored entity and returned in GET. This is implied but not explicit. Minor — add one sentence. **4. The `@Size(max=50)` on `personIds`/`documentIds` — what does the API return when violated?** Bean Validation on `@Size` returns a generic 400 with a validation error body. The issue says "Bean Validation on `@Size` constraints produces 400 automatically — no service-layer code needed." Confirmed. But the acceptance criterion says `more than 50 personIds → 400`. The error response body will be Spring's default validation error format (not a `DomainException` structured error with `ErrorCode`). This is the existing project behavior (consistent with `@Size(max=5000)` on description). Fine — just noting it's a different error shape than `DomainException` errors. **5. No acceptance criterion for the response body shape of `GET /api/timeline/events/{id}`** The spec says the entity is returned directly (no response DTO), consistent with the project pattern, and that `personIds`/`documentIds` (full linked entities) must be exposed. The acceptance criteria don't include: "GET response includes the linked person entities" or "GET response includes the linked document entities." Add this, otherwise the implementer might return a minimal entity without initializing lazy collections (which would surface as empty arrays or a LazyInitializationException if `@Transactional(readOnly = true)` is missing). ### Recommendations - **Add acceptance criterion**: "After update, `createdBy` retains the original creator UUID; only `updatedBy` changes." - **Add acceptance criterion**: "GET `/api/timeline/events/{id}` response includes the full linked person and document entities (not just their IDs), enabling the curator edit form to pre-populate pickers without a second round-trip." - **Add acceptance criterion**: "`precision` defaulted to `YEAR` is persisted and returned in the GET response." - **Add acceptance criterion**: "`eventDateEnd` is null in the GET response when `precision != RANGE`." - These are all implicit in the spec but making them explicit produces unambiguous test cases. ### Open Decisions _(omit this section entirely if none)_ None — all requirements decisions are resolved. These are documentation clarifications, not new decisions.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

This issue is backend-only (no rendered surface — curator forms live in spec issue #9). No UI, no accessibility, no responsive design decisions to review here.

What I checked: the spec says GET /api/timeline/events/{id} must expose full linked person/document entities so the curator edit form (#9) can pre-populate its pickers without a second round-trip. That is the right design — pre-populated pickers are a usability baseline, not a nice-to-have. If the GET response returns empty lazy collections, the form will silently show no linked persons/documents when re-opened for editing. The @Transactional(readOnly = true) on getEvent (required in the spec) is what keeps those collections accessible during serialisation. No changes needed from my perspective — this concern is already captured in the spec.

No concerns from my angle for this backend-only issue. I'll engage fully when issue #9 (curator event forms) arrives.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist This issue is backend-only (no rendered surface — curator forms live in spec issue #9). No UI, no accessibility, no responsive design decisions to review here. What I checked: the spec says `GET /api/timeline/events/{id}` must expose full linked person/document entities so the curator edit form (#9) can pre-populate its pickers without a second round-trip. That is the right design — pre-populated pickers are a usability baseline, not a nice-to-have. If the GET response returns empty lazy collections, the form will silently show no linked persons/documents when re-opened for editing. The `@Transactional(readOnly = true)` on `getEvent` (required in the spec) is what keeps those collections accessible during serialisation. No changes needed from my perspective — this concern is already captured in the spec. No concerns from my angle for this backend-only issue. I'll engage fully when issue #9 (curator event forms) arrives.
Sign in to join this conversation.
No Label P2-medium feature
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#775