Timeline: assembly endpoint GET /api/timeline #777

Open
opened 2026-06-07 19:29:07 +02:00 by marcel · 0 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), derived person-events (issue #4 — this endpoint consumes an already-tested deriveEvents(...); it does not re-implement Geburt/Tod/Heirat logic).

Context

The read endpoint that merges all three layers (curated events + derived person-events + letters) into a year-bucketed structure for the frontend. This is step 5 of the 11-issue Zeitstrahl breakdown and the most coupling-heavy backend slice: TimelineService reads its own TimelineEventRepository plus three other domains (Person, Document, PersonRelationship) — always via their services, not repositories.

Scope

  • GET /api/timeline — gated by @RequirePermission(Permission.READ_ALL). Optional params: personId (UUID), generation (int, @Min(0)), type (PERSONAL/HISTORICAL), fromYear (int), toYear (int).
  • The per-person "Lebensweg" is just ?personId=… — no separate endpoint.
  • Read-only; no write surface, no new ErrorCode, no new Flyway migration, no new infrastructure/Docker/env/config.

Architecture & boundaries

  • Cross-domain reads go through services only: DocumentService.getAllForTimeline(): List<Document> (global path — bulk-fetch in a single query, avoids N+1 fan-out); DocumentService.getDocumentsBySender(UUID) + getDocumentsByReceiver(UUID) (personId path); PersonService.getById(UUID) / getAllById(...) / getPersonsByGeneration(int) (new method — see § New service methods); and relationship data via RelationshipService (not PersonRelationshipService — the class is RelationshipService in person/relationship/). Never reach into another domain's repository.
  • Derived events are consumed from issue #4's deriveEvents(...) output — merged, never re-derived here.
  • No row-level scoping concern: the archive is family-wide; any READ_ALL user already sees all documents/persons. The timeline exposing all letters/events is consistent with the existing read model — not a new data-exposure regression. personId is a lookup key, not an authorization boundary.

New service methods (in scope for this issue)

PersonService.getPersonsByGeneration(int generation): List<Person>

One new method + one new repository method. Delegates to PersonRepository.findByGeneration(int generation) (a single @Query or derived-method on the persons table). Required by the generation filter to resolve person UUIDs without boundary violations or O(all-persons) in-memory filtering.

DocumentService.getAllForTimeline(): List<Document>

One new service method for the global timeline path. Returns all documents in a single Hibernate query (no per-person fan-out). The personId path continues to use getDocumentsBySender + getDocumentsByReceiver and deduplicates.

DTOs (Java record types — immutable read-projections, flat in timeline/; do NOT share with the CRUD TimelineEventRequest input)

  • TimelineEntryDTO:
    • kind (EVENT | LETTER) — @Schema(requiredMode = REQUIRED)
    • eventDate (raw LocalDate), precision (raw DatePrecision enum exposed as string via SpringDoc — never a server-formatted label), eventDateEnd (nullable — for RANGE)
    • title, type (EventType, null for letters), derived (boolean) — @Schema(requiredMode = REQUIRED) on all always-populated fields
    • source id: eventId (null for derived entries and for letters — no @Schema(requiredMode = REQUIRED); frontend cannot wire an edit link to a non-existent event) / documentId (set for letters, no requiredMode = REQUIRED)
    • letter display fields: senderName (String, never null — fallback chain: sender.getDisplayName()document.getSenderText()"") and receiverName (String, never null — same fallback chain via receiverText). The unlinked-correspondent case is historically most common; the DTO must never carry null where the frontend expects a display string.
    • event link fields: linked person ids
    • Edit-affordance contract (for downstream issue #7): derived == true || eventId == null → no edit link should be rendered. Both signals are present in every entry; the frontend EventCard.svelte should check either one.
  • TimelineYearDTO{ year, entries[] }@Schema(requiredMode = REQUIRED) on both fields.
  • TimelineDTO{ years[], undated[] }@Schema(requiredMode = REQUIRED) on both fields.
  • Verify SpringDoc serializes DatePrecision as a string enum in the generated spec (it should via @Enumerated(STRING) analog). Run npm run generate:api after DTOs land and confirm generated TS types are non-optional where annotated.

Resolved decisions

  1. generation filter for letters → sender OR receiver, never duplicated. A letter matches generation=N when its sender OR its receiver is in generation N; if both match it still appears once. Rationale: most intuitive; does not hide received correspondence; same dedup rule as the personId path.
  2. ?type= does NOT filter the letter layer. type filters only the event layer (curated + derived); letters always pass through any type filter. Rationale: type is an EventType property letters do not possess — filtering them by it is meaningless.
  3. Global response: single TimelineDTO payload, no pagination (MVP). Rationale: family-scale archive on i7-6700/64 GB VPS; the year is the rendering axis; frontend virtualizes/lazy-renders bands. If global payload becomes a latency problem, add HTTP Cache-Control/ETag — not a cache layer, not Redis.
  4. All supplied query filters combine with logical AND (type + generation + fromYear/toYear + personId are conjunctive).
  5. fromYear > toYear → HTTP 400 (consistent with RelationshipService.validateYears at line 158–161).
  6. type binds to the EventType enum in the controller signature (not raw String), so Spring's converter rejects unknown values at the boundary with a 400.
  7. Unknown personIdDomainException.notFound (reuse PERSON_NOT_FOUND) via PersonService.getById, not a 500 or a silent empty-200.
  8. Empty scope → HTTP 200 with empty years and undated (e.g. a personId with zero events and zero letters). Not a 404.
  9. PersonService.getPersonsByGeneration(int) → Option A: add to this issue's scope. One new service method + one new repository method. Clean delegation, no boundary violation, no O(all-persons) in-memory filtering. Rationale: Option B (fetch all + filter) is wasteful and fragile; Option A is one line each.
  10. DocumentService.getAllForTimeline() → add explicitly to scope and task list. Named as getAllForTimeline(): List<Document>. Rationale: the existing getDocumentsBySender/getDocumentsByReceiver are per-person queries and cannot serve the global path without an N+1 fan-out.
  11. CLAUDE.md package table → update alongside the C4 diagram. Both are doc tasks in this issue's scope. Rationale: CLAUDE.md is the primary developer onboarding reference; an unlisted package misleads future contributors.
  12. Year-band ordering → ascending (oldest year first). Rationale: "history unfolding" scroll direction aligns with the design spec's stated UX goal; matches natural reading of a vertical timeline. Add "ascending chronological order" to acceptance criteria and DTO Javadoc.
  13. personId + generation combined → AND logic; may yield empty results. If the target person is in generation 3 and generation=4 is passed, the result is empty. No special-case: AND composition applies strictly. Rationale: consistent with filter semantics; document explicitly.
  14. RANGE event + fromYear/toYear filter → filter by start year (eventDate.getYear()). A RANGE event whose start year is outside [fromYear, toYear] is excluded even if its end year falls within range. Rationale: consistent with band placement (start-year band); simpler and unambiguous.
  15. Letter with null-generation sender AND receiver + generation=N filter → returns nothing. Null generation does not match any N. Rationale: null is not "in any generation"; filtering should only surface items with a confirmed match.
  16. generation parameter → @Min(0) validation. Add @RequestParam(required = false) @Min(0) Integer generation on the controller method. generation=-1 returns HTTP 400. Rationale: clearly invalid values should fail fast at the boundary rather than silently returning empty results.
  17. senderName/receiverName are String (never null) in the DTO record. Ultimate fallback is "". Rationale: the frontend LetterCard.svelte must never receive null where it expects a display string.
  18. withinBandComparator → named Comparator constant, not inlined. Defined as a package-private named constant (e.g. static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER = ...). Rationale: independently testable; readable; not buried in a stream pipeline.

Bucketing & ordering rules

  • Letters are placed by Document.documentDate.getYear().
  • Undated bucket has two distinct triggers: a letter lands in undated when documentDate == null OR metaDatePrecision == UNKNOWN. (documentDate is nullable independent of precision — guard the .getYear() NPE.)
  • RANGE events appear only in their start-year band (not duplicated); an open-ended RANGE with null eventDateEnd still places in the start-year band without crashing.
  • fromYear/toYear for RANGE events: filter by start year. A RANGE event outside [fromYear, toYear] on its start year is excluded — even if its end year falls within the range. Consistent with band placement.
  • Within-band ordering must be a deterministic total order. Implemented as a named Comparator constant (WITHIN_BAND_ORDER): sort by full date when precision allows; tiebreak by precision granularity (DAY > MONTH > SEASON > YEAR > APPROX), then title alphabetically, then id as final tiebreak so the same payload never reorders between requests.
  • Years (bands) are ordered ascending (oldest first). Descending order is not used.
  • Carry derived and type through so the frontend can apply personal vs muted "world" accents and suppress the edit affordance on derived events. Label/German formatting (dateLabel.ts, formatTickLabel) is frontend step 6 — do not leak it into the backend DTO.

Performance

  • Global path: DocumentService.getAllForTimeline() bulk-fetches all documents in a single query; assemble in memory. Do NOT loop getDocumentsBySender per person (avoid O(persons) fan-out / N+1).
  • personId path: compose getDocumentsBySender(id) + getDocumentsByReceiver(id), deduping a letter where the same person is both sender and receiver.
  • Sanity-check that the global call's p95 stays under the project's 500ms smoke gate once real data is loaded (visible in existing Tempo/Prometheus; no new dashboard).

Tasks

  • PersonService.getPersonsByGeneration(int generation): List<Person> + PersonRepository.findByGeneration(int generation) — one method each.
  • DocumentService.getAllForTimeline(): List<Document> — bulk single-query method for the global timeline path.
  • TimelineService.assemble(...) orchestrator (~20 lines, reads like prose) merging curated events + derived events (from #4) + letters.
  • Extract package-private bucketByYear(...) and named WITHIN_BAND_ORDER Comparator constant as independently testable units.
  • Bucketing rule: bucket by documentDate.getYear() when documentDate != null AND precision != UNKNOWN; otherwise undated.
  • RANGE shown in start-year band only; null eventDateEnd handled; fromYear/toYear filter tests RANGE event's start year only.
  • Apply filters with logical AND; generation letter-match = sender OR receiver, deduped; type filters events only, letters always pass; personId + generation AND logic (may yield empty).
  • fromYear > toYear → 400; type bound to EventType enum; @Min(0) on generation param (invalid → 400).
  • Reach Document/Person/relationship data via their services only (RelationshipService, not PersonRelationshipService).
  • record DTOs with @Schema(requiredMode = REQUIRED) on always-populated fields; senderName/receiverName are String (never null, fallback ""); eventId null for derived + letters; raw precision enum exposed; year bands ordered ascending.
  • Javadoc on TimelineEntryDTO: document derived == true || eventId == null as the "no edit affordance" contract for issue #7.
  • @RequirePermission(Permission.READ_ALL) on the controller method.
  • npm run generate:api and verify generated TS types are non-optional where @Schema(requiredMode = REQUIRED) is annotated.
  • Doc tasks (non-optional): (1) update docs/architecture/c4/l3-backend-*.puml for the new timeline/ domain (new TimelineService read methods + DTOs); (2) add timeline/ entry to the package structure table in CLAUDE.md. No ADR triggered by this issue itself (the ADR for the timeline/ domain is allocated when the entity issue #2 lands).

Acceptance criteria

  • Years are ordered ascending (oldest first); each band holds its events + letters; undated bucket populated when documentDate is null OR precision is UNKNOWN.
  • ?personId= returns only that person's events + sent/received letters; a letter where the person is both sender and receiver appears once.
  • Filters (type, fromYear/toYear, generation, personId) narrow results correctly and combine with logical AND.
  • personId + generation combined: AND logic applies strictly — if the target person's generation does not match the generation filter, the result is empty.
  • A letter matches generation=N when its sender OR receiver is in generation N; not duplicated if both match.
  • A letter where both sender and receiver have null generation does not appear in any ?generation=N filtered response.
  • ?type= filters only the event layer; letters always pass.
  • A RANGE event's year filter is tested against eventDate.getYear() (start year), consistent with its band placement. A RANGE event whose start year is outside [fromYear, toYear] is excluded even if its end year falls within range.
  • A scope with no matching items returns 200 with empty years and undated.
  • fromYear > toYear returns 400; fromYear == toYear is an inclusive single-year window.
  • generation < 0 returns 400 (Bean Validation @Min(0)).
  • Unknown personId returns 404 with PERSON_NOT_FOUND.
  • DTO carries raw precision enum, eventDate, nullable eventDateEnd, derived, type, and senderName/receiverName as non-null strings with free-text fallback; eventId is null for derived entries and letters; documentId is null for events.

Tests (pyramid: bulk of coverage as fast Mockito unit tests <10s; Testcontainers postgres:16-alpine, never H2)

  • TimelineServiceTest (Mockito, mock at the service boundary) — ~18-22 cases. TDD order:
    1. empty archive → empty TimelineDTO
    2. one YEAR letter → one year band with one entry
    3. null-date letter → undated; UNKNOWN-precision letter → undated (two separate code paths, both tested)
    4. letter with null sender AND null senderText → senderName is "" (not NPE, not null)
    5. two letters same year, DAY vs YEARDAY sorts first (assert the full sequence, not "contains")
    6. two letters, same year, same DAY precision, same date → sorted by title alphabetically (exercises title tiebreak in WITHIN_BAND_ORDER)
    7. RANGE event → appears only in start-year band; open-ended RANGE with null eventDateEnd → start-year band, no crash
    8. RANGE event + fromYear/toYear: event with start year outside range is excluded even if end year overlaps
    9. each filter: type (letters survive), fromYear/toYear (inclusive; fromYear == toYear single-year window — guard against off-by-one), generation (sender-or-receiver match, deduped), personId
    10. combined filter: type=HISTORICAL + fromYear=1914 + toYear=1918 → AND composition verified
    11. personId scoping returns only sent+received letters + that person's derived events; same-person sender+receiver letter appears once
    12. personId + generation combined → empty result when person's generation doesn't match filter
    13. letter with null-generation sender and receiver + generation=N → not returned
    14. year band with only an event and no letters, and vice versa
    • Use makeLetter(year, precision) / makeEvent(...) factories so each test body is one line of setup + one assertion. The makeLetter factory must support a nullSenderAndNullSenderText variant.
  • TimelineServiceIntegrationTest (@DataJpaTest + Testcontainers postgres:16-alpine) — curated-event persistence + assembled read against real Postgres (LocalDate/null column behavior); a handful of cases.
  • TimelineControllerTest (@WebMvcTest + SecurityConfig + PermissionAspect + AopAutoConfiguration) — following the pattern in UserSearchControllerTest:
    • 200 with READ_ALL
    • 403 authenticated without permission
    • 401 unauthenticated
    • 400 on bad type value (unknown enum string)
    • 400 on fromYear > toYear
    • 400 on generation=-1 (Bean Validation @Min(0))
    • 404 with PERSON_NOT_FOUND body when service throws DomainException.notFound (mock TimelineService.assemble(...) to throw; assert status + error code)
    • param binding (valid params reach the service)
**Milestone:** Zeitstrahl — Family Timeline **Spec:** `docs/superpowers/specs/2026-06-07-family-timeline-design.md` § "Assembly & API" **Depends on:** TimelineEvent entity (issue #2), derived person-events (issue #4 — this endpoint *consumes* an already-tested `deriveEvents(...)`; it does **not** re-implement Geburt/Tod/Heirat logic). ## Context The read endpoint that merges all three layers (curated events + derived person-events + letters) into a year-bucketed structure for the frontend. This is step 5 of the 11-issue Zeitstrahl breakdown and the most coupling-heavy backend slice: `TimelineService` reads its own `TimelineEventRepository` plus three other domains (Person, Document, PersonRelationship) — always via their **services, not repositories**. ## Scope - `GET /api/timeline` — gated by `@RequirePermission(Permission.READ_ALL)`. Optional params: `personId` (UUID), `generation` (int, `@Min(0)`), `type` (`PERSONAL`/`HISTORICAL`), `fromYear` (int), `toYear` (int). - The per-person "Lebensweg" is just `?personId=…` — no separate endpoint. - Read-only; no write surface, no new `ErrorCode`, no new Flyway migration, no new infrastructure/Docker/env/config. ## Architecture & boundaries - Cross-domain reads go through services only: `DocumentService.getAllForTimeline(): List<Document>` (global path — bulk-fetch in a single query, avoids N+1 fan-out); `DocumentService.getDocumentsBySender(UUID)` + `getDocumentsByReceiver(UUID)` (personId path); `PersonService.getById(UUID)` / `getAllById(...)` / `getPersonsByGeneration(int)` (new method — see § New service methods); and relationship data via `RelationshipService` (not `PersonRelationshipService` — the class is `RelationshipService` in `person/relationship/`). Never reach into another domain's repository. - Derived events are consumed from issue #4's `deriveEvents(...)` output — merged, never re-derived here. - **No row-level scoping concern:** the archive is family-wide; any `READ_ALL` user already sees all documents/persons. The timeline exposing all letters/events is consistent with the existing read model — not a new data-exposure regression. `personId` is a lookup key, not an authorization boundary. ## New service methods (in scope for this issue) ### `PersonService.getPersonsByGeneration(int generation): List<Person>` One new method + one new repository method. Delegates to `PersonRepository.findByGeneration(int generation)` (a single `@Query` or derived-method on the `persons` table). Required by the `generation` filter to resolve person UUIDs without boundary violations or O(all-persons) in-memory filtering. ### `DocumentService.getAllForTimeline(): List<Document>` One new service method for the global timeline path. Returns all documents in a single Hibernate query (no per-person fan-out). The `personId` path continues to use `getDocumentsBySender` + `getDocumentsByReceiver` and deduplicates. ## DTOs (Java `record` types — immutable read-projections, flat in `timeline/`; do NOT share with the CRUD `TimelineEventRequest` input) - `TimelineEntryDTO`: - `kind` (`EVENT` | `LETTER`) — `@Schema(requiredMode = REQUIRED)` - `eventDate` (raw `LocalDate`), `precision` (raw `DatePrecision` **enum** exposed as string via SpringDoc — never a server-formatted label), `eventDateEnd` (nullable — for `RANGE`) - `title`, `type` (`EventType`, null for letters), `derived` (boolean) — `@Schema(requiredMode = REQUIRED)` on all always-populated fields - source id: `eventId` (null for derived entries and for letters — no `@Schema(requiredMode = REQUIRED)`; frontend cannot wire an edit link to a non-existent event) / `documentId` (set for letters, no `requiredMode = REQUIRED`) - letter display fields: `senderName` (`String`, never null — fallback chain: `sender.getDisplayName()` → `document.getSenderText()` → `""`) and `receiverName` (`String`, never null — same fallback chain via `receiverText`). The unlinked-correspondent case is historically most common; the DTO must never carry null where the frontend expects a display string. - event link fields: linked person ids - **Edit-affordance contract (for downstream issue #7):** `derived == true || eventId == null` → no edit link should be rendered. Both signals are present in every entry; the frontend `EventCard.svelte` should check either one. - `TimelineYearDTO` — `{ year, entries[] }` — `@Schema(requiredMode = REQUIRED)` on both fields. - `TimelineDTO` — `{ years[], undated[] }` — `@Schema(requiredMode = REQUIRED)` on both fields. - Verify SpringDoc serializes `DatePrecision` as a string enum in the generated spec (it should via `@Enumerated(STRING)` analog). Run `npm run generate:api` after DTOs land and confirm generated TS types are non-optional where annotated. ## Resolved decisions 1. **`generation` filter for letters → sender OR receiver, never duplicated.** A letter matches `generation=N` when its sender OR its receiver is in generation N; if both match it still appears once. Rationale: most intuitive; does not hide received correspondence; same dedup rule as the `personId` path. 2. **`?type=` does NOT filter the letter layer.** `type` filters only the event layer (curated + derived); letters always pass through any `type` filter. Rationale: `type` is an `EventType` property letters do not possess — filtering them by it is meaningless. 3. **Global response: single `TimelineDTO` payload, no pagination (MVP).** Rationale: family-scale archive on i7-6700/64 GB VPS; the year is the rendering axis; frontend virtualizes/lazy-renders bands. If global payload becomes a latency problem, add HTTP `Cache-Control`/ETag — not a cache layer, not Redis. 4. **All supplied query filters combine with logical AND** (`type` + `generation` + `fromYear`/`toYear` + `personId` are conjunctive). 5. **`fromYear > toYear` → HTTP 400** (consistent with `RelationshipService.validateYears` at line 158–161). 6. **`type` binds to the `EventType` enum** in the controller signature (not raw `String`), so Spring's converter rejects unknown values at the boundary with a 400. 7. **Unknown `personId` → `DomainException.notFound` (reuse `PERSON_NOT_FOUND`)** via `PersonService.getById`, not a 500 or a silent empty-200. 8. **Empty scope → HTTP 200 with empty `years` and `undated`** (e.g. a `personId` with zero events and zero letters). Not a 404. 9. **`PersonService.getPersonsByGeneration(int)` → Option A: add to this issue's scope.** One new service method + one new repository method. Clean delegation, no boundary violation, no O(all-persons) in-memory filtering. Rationale: Option B (fetch all + filter) is wasteful and fragile; Option A is one line each. 10. **`DocumentService.getAllForTimeline()` → add explicitly to scope and task list.** Named as `getAllForTimeline(): List<Document>`. Rationale: the existing `getDocumentsBySender/getDocumentsByReceiver` are per-person queries and cannot serve the global path without an N+1 fan-out. 11. **`CLAUDE.md` package table → update alongside the C4 diagram.** Both are doc tasks in this issue's scope. Rationale: `CLAUDE.md` is the primary developer onboarding reference; an unlisted package misleads future contributors. 12. **Year-band ordering → ascending (oldest year first).** Rationale: "history unfolding" scroll direction aligns with the design spec's stated UX goal; matches natural reading of a vertical timeline. Add "ascending chronological order" to acceptance criteria and DTO Javadoc. 13. **`personId` + `generation` combined → AND logic; may yield empty results.** If the target person is in generation 3 and `generation=4` is passed, the result is empty. No special-case: AND composition applies strictly. Rationale: consistent with filter semantics; document explicitly. 14. **RANGE event + `fromYear`/`toYear` filter → filter by start year (`eventDate.getYear()`).** A RANGE event whose start year is outside `[fromYear, toYear]` is excluded even if its end year falls within range. Rationale: consistent with band placement (start-year band); simpler and unambiguous. 15. **Letter with null-generation sender AND receiver + `generation=N` filter → returns nothing.** Null generation does not match any N. Rationale: null is not "in any generation"; filtering should only surface items with a confirmed match. 16. **`generation` parameter → `@Min(0)` validation.** Add `@RequestParam(required = false) @Min(0) Integer generation` on the controller method. `generation=-1` returns HTTP 400. Rationale: clearly invalid values should fail fast at the boundary rather than silently returning empty results. 17. **`senderName`/`receiverName` are `String` (never null) in the DTO record.** Ultimate fallback is `""`. Rationale: the frontend `LetterCard.svelte` must never receive null where it expects a display string. 18. **`withinBandComparator` → named `Comparator` constant, not inlined.** Defined as a package-private named constant (e.g. `static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER = ...`). Rationale: independently testable; readable; not buried in a stream pipeline. ## Bucketing & ordering rules - Letters are placed by `Document.documentDate.getYear()`. - **Undated bucket has two distinct triggers:** a letter lands in `undated` when `documentDate == null` **OR** `metaDatePrecision == UNKNOWN`. (`documentDate` is nullable independent of precision — guard the `.getYear()` NPE.) - `RANGE` events appear **only in their start-year band** (not duplicated); an open-ended `RANGE` with null `eventDateEnd` still places in the start-year band without crashing. - **`fromYear`/`toYear` for RANGE events: filter by start year.** A RANGE event outside `[fromYear, toYear]` on its start year is excluded — even if its end year falls within the range. Consistent with band placement. - **Within-band ordering must be a deterministic total order.** Implemented as a named `Comparator` constant (`WITHIN_BAND_ORDER`): sort by full date when precision allows; tiebreak by precision granularity (`DAY` > `MONTH` > `SEASON` > `YEAR` > `APPROX`), then title alphabetically, then id as final tiebreak so the same payload never reorders between requests. - **Years (bands) are ordered ascending (oldest first).** Descending order is not used. - Carry `derived` and `type` through so the frontend can apply personal vs muted "world" accents and suppress the edit affordance on derived events. Label/German formatting (`dateLabel.ts`, `formatTickLabel`) is frontend step 6 — do **not** leak it into the backend DTO. ## Performance - **Global path:** `DocumentService.getAllForTimeline()` bulk-fetches all documents in a single query; assemble in memory. Do NOT loop `getDocumentsBySender` per person (avoid O(persons) fan-out / N+1). - **`personId` path:** compose `getDocumentsBySender(id)` + `getDocumentsByReceiver(id)`, deduping a letter where the same person is both sender and receiver. - Sanity-check that the global call's p95 stays under the project's 500ms smoke gate once real data is loaded (visible in existing Tempo/Prometheus; no new dashboard). ## Tasks - [ ] `PersonService.getPersonsByGeneration(int generation): List<Person>` + `PersonRepository.findByGeneration(int generation)` — one method each. - [ ] `DocumentService.getAllForTimeline(): List<Document>` — bulk single-query method for the global timeline path. - [ ] `TimelineService.assemble(...)` orchestrator (~20 lines, reads like prose) merging curated events + derived events (from #4) + letters. - [ ] Extract package-private `bucketByYear(...)` and named `WITHIN_BAND_ORDER` `Comparator` constant as independently testable units. - [ ] Bucketing rule: bucket by `documentDate.getYear()` when `documentDate != null` AND `precision != UNKNOWN`; otherwise `undated`. - [ ] `RANGE` shown in start-year band only; null `eventDateEnd` handled; `fromYear`/`toYear` filter tests RANGE event's start year only. - [ ] Apply filters with logical AND; `generation` letter-match = sender OR receiver, deduped; `type` filters events only, letters always pass; `personId` + `generation` AND logic (may yield empty). - [ ] `fromYear > toYear` → 400; `type` bound to `EventType` enum; `@Min(0)` on `generation` param (invalid → 400). - [ ] Reach Document/Person/relationship data via their services only (`RelationshipService`, not `PersonRelationshipService`). - [ ] `record` DTOs with `@Schema(requiredMode = REQUIRED)` on always-populated fields; `senderName`/`receiverName` are `String` (never null, fallback `""`); `eventId` null for derived + letters; raw `precision` enum exposed; year bands ordered ascending. - [ ] Javadoc on `TimelineEntryDTO`: document `derived == true || eventId == null` as the "no edit affordance" contract for issue #7. - [ ] `@RequirePermission(Permission.READ_ALL)` on the controller method. - [ ] `npm run generate:api` and verify generated TS types are non-optional where `@Schema(requiredMode = REQUIRED)` is annotated. - [ ] **Doc tasks (non-optional):** (1) update `docs/architecture/c4/l3-backend-*.puml` for the new `timeline/` domain (new `TimelineService` read methods + DTOs); (2) add `timeline/` entry to the package structure table in `CLAUDE.md`. No ADR triggered by this issue itself (the ADR for the `timeline/` domain is allocated when the entity issue #2 lands). ## Acceptance criteria - Years are ordered **ascending (oldest first)**; each band holds its events + letters; undated bucket populated when `documentDate` is null OR precision is `UNKNOWN`. - `?personId=` returns only that person's events + sent/received letters; a letter where the person is both sender and receiver appears once. - Filters (`type`, `fromYear`/`toYear`, `generation`, `personId`) narrow results correctly and combine with logical AND. - `personId` + `generation` combined: AND logic applies strictly — if the target person's generation does not match the `generation` filter, the result is empty. - A letter matches `generation=N` when its sender OR receiver is in generation N; not duplicated if both match. - A letter where both sender and receiver have null `generation` does not appear in any `?generation=N` filtered response. - `?type=` filters only the event layer; letters always pass. - A RANGE event's year filter is tested against `eventDate.getYear()` (start year), consistent with its band placement. A RANGE event whose start year is outside `[fromYear, toYear]` is excluded even if its end year falls within range. - A scope with no matching items returns 200 with empty `years` and `undated`. - `fromYear > toYear` returns 400; `fromYear == toYear` is an inclusive single-year window. - `generation < 0` returns 400 (Bean Validation `@Min(0)`). - Unknown `personId` returns 404 with `PERSON_NOT_FOUND`. - DTO carries raw `precision` enum, `eventDate`, nullable `eventDateEnd`, `derived`, `type`, and `senderName`/`receiverName` as non-null strings with free-text fallback; `eventId` is null for derived entries and letters; `documentId` is null for events. ## Tests (pyramid: bulk of coverage as fast Mockito unit tests <10s; Testcontainers `postgres:16-alpine`, never H2) - **`TimelineServiceTest` (Mockito, mock at the service boundary)** — ~18-22 cases. TDD order: 1. empty archive → empty `TimelineDTO` 2. one `YEAR` letter → one year band with one entry 3. null-date letter → undated; `UNKNOWN`-precision letter → undated (two separate code paths, both tested) 4. **letter with null sender AND null senderText → `senderName` is `""` (not NPE, not null)** 5. two letters same year, `DAY` vs `YEAR` → `DAY` sorts first (assert the **full sequence**, not "contains") 6. **two letters, same year, same `DAY` precision, same date → sorted by title alphabetically** (exercises title tiebreak in `WITHIN_BAND_ORDER`) 7. `RANGE` event → appears only in start-year band; open-ended `RANGE` with null `eventDateEnd` → start-year band, no crash 8. **RANGE event + `fromYear`/`toYear`: event with start year outside range is excluded even if end year overlaps** 9. each filter: `type` (letters survive), `fromYear`/`toYear` (inclusive; **`fromYear == toYear` single-year window — guard against off-by-one**), `generation` (sender-or-receiver match, deduped), `personId` 10. **combined filter: `type=HISTORICAL` + `fromYear=1914` + `toYear=1918` → AND composition verified** 11. `personId` scoping returns only sent+received letters + that person's derived events; same-person sender+receiver letter appears once 12. **`personId` + `generation` combined → empty result when person's generation doesn't match filter** 13. **letter with null-generation sender and receiver + `generation=N` → not returned** 14. year band with only an event and no letters, and vice versa - Use `makeLetter(year, precision)` / `makeEvent(...)` factories so each test body is one line of setup + one assertion. The `makeLetter` factory must support a `nullSenderAndNullSenderText` variant. - **`TimelineServiceIntegrationTest` (`@DataJpaTest` + Testcontainers postgres:16-alpine)** — curated-event persistence + assembled read against real Postgres (`LocalDate`/null column behavior); a handful of cases. - **`TimelineControllerTest` (`@WebMvcTest` + `SecurityConfig` + `PermissionAspect` + `AopAutoConfiguration`)** — following the pattern in `UserSearchControllerTest`: - 200 with `READ_ALL` - 403 authenticated without permission - 401 unauthenticated - 400 on bad `type` value (unknown enum string) - 400 on `fromYear > toYear` - **400 on `generation=-1`** (Bean Validation `@Min(0)`) - **404 with `PERSON_NOT_FOUND` body when service throws `DomainException.notFound`** (mock `TimelineService.assemble(...)` to throw; assert status + error code) - param binding (valid params reach the service)
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-07 19:29:07 +02:00
marcel added the P2-mediumfeature labels 2026-06-07 19:30:00 +02:00
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#777