diff --git a/CLAUDE.md b/CLAUDE.md index ae9127c0..810524b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ backend/src/main/java/org/raddatz/familienarchiv/ │ └── relationship/ PersonRelationship sub-domain ├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── tag/ Tag domain +├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository └── user/ User domain — AppUser, UserGroup, UserService ``` @@ -115,6 +116,7 @@ backend/src/main/java/org/raddatz/familienarchiv/ | `UserGroup` | `user_groups` | Has a `Set permissions` | | `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) | | `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` | +| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail | **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 38d5b08b..cf063271 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -42,6 +42,7 @@ src/main/java/org/raddatz/familienarchiv/ │ └── relationship/ # PersonRelationship sub-domain ├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── tag/ # Tag domain — Tag, TagService, TagController +├── timeline/ # Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository └── user/ # User domain — AppUser, UserGroup, UserService ``` @@ -67,6 +68,7 @@ For per-domain ownership and public surface, see each domain's `README.md`. | `Comment` | `document_comments` | Threaded comments with mentions | | `Notification` | `notifications` | User notification feed | | `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking | +| `TimelineEvent` | `timeline_events` | Curated Zeitstrahl event; ManyToMany persons + documents (join FKs ON DELETE CASCADE); `@Version` + NOT NULL createdBy/updatedBy | **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/EventType.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/EventType.java new file mode 100644 index 00000000..f392f3c5 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/EventType.java @@ -0,0 +1,17 @@ +package org.raddatz.familienarchiv.timeline; + +/** + * Kind of a curated {@link TimelineEvent}. + * + *

The string value names are a stable frontend styling contract: the + * Svelte timeline components hard-code {@code "PERSONAL"} (family accent) and + * {@code "HISTORICAL"} (muted world accent) as Tailwind class-map keys. There is no + * mapping layer — renaming either value requires a coordinated frontend change. See + * ADR-040. + */ +public enum EventType { + /** A family/personal event (birth, wedding, move) — rendered with the family accent. */ + PERSONAL, + /** A world/historical event providing context — rendered with the muted world accent. */ + HISTORICAL +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEvent.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEvent.java new file mode 100644 index 00000000..d6be0355 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEvent.java @@ -0,0 +1,134 @@ +package org.raddatz.familienarchiv.timeline; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.BatchSize; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.person.Person; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +/** + * A curated event on the family timeline (Zeitstrahl). Unlike a {@link Document}, which is + * OCR-derived, a {@code TimelineEvent} is authored by curators — hence the optimistic-lock + * {@link #version} and the {@link #createdBy}/{@link #updatedBy} audit trail that + * {@code Document} lacks. + * + *

The date block ({@link #eventDate}, {@link #precision}, {@link #eventDateEnd}) mirrors + * {@code Document}'s so events and letters share one rendering path. The mirror applies to + * the date block only — the audit footprint deliberately diverges (see ADR-040). + */ +@Entity +@Table(name = "timeline_events") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TimelineEvent { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private EventType type; + + /** The most precise date known for the event. Always present — a curated event is never undated. */ + @Column(name = "event_date", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDate eventDate; + + /** + * Precision of {@link #eventDate}. Reuses {@code document.DatePrecision} (one rendering + * path; see ADR-025 / ADR-040). Every value except {@code UNKNOWN} is legal for a curated + * event — including {@code SEASON} ("Sommer 1914") and {@code APPROX} ("ca. 1914"). The DB + * CHECK forbids exactly {@code UNKNOWN}; do not narrow it further. + */ + @Enumerated(EnumType.STRING) + @Column(name = "date_precision", nullable = false, length = 16) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private DatePrecision precision = DatePrecision.YEAR; + + /** Range end — non-null iff {@link #precision} is {@code RANGE} (DB CHECK, both directions). */ + @Column(name = "event_date_end") + private LocalDate eventDateEnd; + + @Column(columnDefinition = "TEXT") + private String description; + + /** People the event involves. */ + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "timeline_event_persons", + joinColumns = @JoinColumn(name = "timeline_event_id"), + inverseJoinColumns = @JoinColumn(name = "person_id")) + @BatchSize(size = 50) + @Builder.Default + private Set persons = new HashSet<>(); + + /** Optional supporting letters linked to the event. */ + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "timeline_event_documents", + joinColumns = @JoinColumn(name = "timeline_event_id"), + inverseJoinColumns = @JoinColumn(name = "document_id")) + @BatchSize(size = 50) + @Builder.Default + private Set documents = new HashSet<>(); + + /** + * UUID of the {@code AppUser} who created the event. Bare UUID, no FK to {@code app_users} + * (sidecar pattern — keeps {@code timeline} decoupled from {@code user}). Server-populated + * from the session principal; never accepted from client input (authorship-forgery vector, + * CWE-639 — see ADR-040). + */ + @Column(name = "created_by", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID createdBy; + + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; + + /** + * UUID of the {@code AppUser} who last edited the event. Populated from the session + * principal in {@code TimelineEventService}; must be set before every + * {@code save()} — {@code @UpdateTimestamp} on {@link #updatedAt} does NOT set this + * automatically, so without an explicit set the timestamp advances while the "who" goes + * stale. Same forgery rationale as {@link #createdBy}. + */ + @Column(name = "updated_by", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID updatedBy; + + @UpdateTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updatedAt; + + /** + * Optimistic-lock version for the multi-curator edit flow (#775). Object {@code Long} + * (not primitive) so it is {@code null} before first persist; Hibernate sets {@code 0} on + * insert. A concurrent-write conflict must be translated to {@code DomainException.conflict} + * in the service layer (ADR-040) — otherwise it surfaces as HTTP 500 with Hibernate + * internals (CWE-209). + */ + @Version + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long version; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRepository.java new file mode 100644 index 00000000..db3b923b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRepository.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.timeline; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface TimelineEventRepository extends JpaRepository { + // TODO(#777): findByPersonsContaining(Person) needed for the per-person filter +} diff --git a/backend/src/main/resources/db/migration/V77__add_timeline_events.sql b/backend/src/main/resources/db/migration/V77__add_timeline_events.sql new file mode 100644 index 00000000..b83745e2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V77__add_timeline_events.sql @@ -0,0 +1,64 @@ +-- V77: timeline domain foundation (Zeitstrahl) — curated timeline events. +-- Forward-only, additive DDL. No rollback script: rollback requires manual DROP TABLE +-- (timeline_event_documents, timeline_event_persons, then timeline_events). See ADR-040. +-- +-- The date block (event_date / date_precision / event_date_end) mirrors documents' so events +-- and letters share one rendering path. Two divergences from documents are INTENTIONAL and +-- enforced here in Postgres (ADR-040): +-- 1. The RANGE rule is a strict biconditional (event_date_end non-null IFF RANGE), unlike +-- documents' open-ended ranges — a curated event always has a known, closed end. +-- 2. date_precision <> 'UNKNOWN' — only OCR-inferred letters are ever undated; a curated +-- event always has at least a year. SEASON and APPROX stay legal (Sommer/ca. 1914). + +CREATE TABLE timeline_events ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + type VARCHAR(16) NOT NULL, + event_date DATE NOT NULL, + date_precision VARCHAR(16) NOT NULL DEFAULT 'YEAR', + event_date_end DATE, + description TEXT, + created_by UUID NOT NULL, + created_at TIMESTAMP, + updated_by UUID NOT NULL, + updated_at TIMESTAMP, + version BIGINT, + CONSTRAINT pk_timeline_events PRIMARY KEY (id), + -- event_date_end is non-null IFF precision is RANGE (both directions). + CONSTRAINT chk_timeline_event_range + CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL)), + -- Curated events are never undated. Forbids exactly UNKNOWN — every other + -- DatePrecision value (DAY, MONTH, SEASON, YEAR, RANGE, APPROX) stays legal. + CONSTRAINT chk_timeline_event_precision + CHECK (date_precision <> 'UNKNOWN') +); + +-- Join table: events ↔ persons involved. +CREATE TABLE timeline_event_persons ( + timeline_event_id UUID NOT NULL, + person_id UUID NOT NULL, + CONSTRAINT pk_timeline_event_persons PRIMARY KEY (timeline_event_id, person_id), + CONSTRAINT fk_tep_event + FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE, + CONSTRAINT fk_tep_person + FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE +); + +-- Join table: events ↔ supporting letters. +CREATE TABLE timeline_event_documents ( + timeline_event_id UUID NOT NULL, + document_id UUID NOT NULL, + CONSTRAINT pk_timeline_event_documents PRIMARY KEY (timeline_event_id, document_id), + CONSTRAINT fk_ted_event + FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE, + CONSTRAINT fk_ted_document + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE +); + +-- Indexes added up-front (avoid the V62 FK-index retrofit debt): the two query columns plus +-- the inverse-side FK columns. timeline_event_id needs no extra index on either join table — +-- it is the leading column of the composite PK, so the PK index already serves those lookups. +CREATE INDEX idx_timeline_events_event_date ON timeline_events (event_date); +CREATE INDEX idx_timeline_events_type ON timeline_events (type); +CREATE INDEX idx_timeline_event_persons_person_id ON timeline_event_persons (person_id); +CREATE INDEX idx_timeline_event_documents_document_id ON timeline_event_documents (document_id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java index 809da1fc..0d34b257 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java @@ -122,7 +122,8 @@ class ArchitectureTest { .that().areAnnotatedWith(Entity.class) .should().resideInAnyPackage( "..document..", "..person..", "..tag..", "..user..", - "..geschichte..", "..notification..", "..ocr..", "..audit.." + "..geschichte..", "..notification..", "..ocr..", "..audit..", + "..timeline.." ); // TODO Rule 5: Controllers expose endpoints under their domain prefix diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventCascadeIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventCascadeIntegrationTest.java new file mode 100644 index 00000000..66b1df3e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventCascadeIntegrationTest.java @@ -0,0 +1,95 @@ +package org.raddatz.familienarchiv.timeline; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentRepository; +import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.document.DocumentStatus; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.raddatz.familienarchiv.person.PersonService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Proves V77's FK {@code ON DELETE CASCADE} on the join tables: deleting a linked Person or + * Document drops the join row and leaves the {@link TimelineEvent} intact (a person/document + * delete must never 500 — V71-class regression guard). Needs the full Spring context for + * {@link PersonService}/{@link DocumentService}, mirroring {@code PersonServiceIntegrationTest}. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@Transactional +class TimelineEventCascadeIntegrationTest { + + @MockitoBean S3Client s3Client; + @Autowired TimelineEventRepository events; + @Autowired PersonRepository personRepository; + @Autowired PersonService personService; + @Autowired DocumentRepository documentRepository; + @Autowired DocumentService documentService; + @Autowired JdbcTemplate jdbc; + @PersistenceContext EntityManager em; + + private TimelineEvent.TimelineEventBuilder makeEvent() { + return TimelineEvent.builder() + .title("Hochzeit von Anna und Otto") + .type(EventType.PERSONAL) + .eventDate(LocalDate.of(1914, 7, 28)) + .createdBy(UUID.randomUUID()) + .updatedBy(UUID.randomUUID()); + } + + @Test + void deleting_linked_person_keeps_event_and_drops_join_row() { + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Raddatz").build()); + TimelineEvent event = events.save(makeEvent().persons(Set.of(anna)).build()); + em.flush(); + em.clear(); + + personService.deletePerson(anna.getId()); + em.flush(); + em.clear(); + + assertThat(events.findById(event.getId())).isPresent(); + Integer joinRows = jdbc.queryForObject( + "SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?", + Integer.class, event.getId()); + assertThat(joinRows).isZero(); + } + + @Test + void deleting_linked_document_keeps_event_and_drops_join_row() { + Document letter = documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build()); + TimelineEvent event = events.save(makeEvent().documents(Set.of(letter)).build()); + em.flush(); + em.clear(); + + documentService.deleteDocument(letter.getId(), UUID.randomUUID()); + em.flush(); + em.clear(); + + assertThat(events.findById(event.getId())).isPresent(); + Integer joinRows = jdbc.queryForObject( + "SELECT COUNT(*) FROM timeline_event_documents WHERE timeline_event_id = ?", + Integer.class, event.getId()); + assertThat(joinRows).isZero(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventTest.java new file mode 100644 index 00000000..2d6f6435 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventTest.java @@ -0,0 +1,202 @@ +package org.raddatz.familienarchiv.timeline; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentRepository; +import org.raddatz.familienarchiv.document.DocumentStatus; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Persistence + DB-constraint tests for {@link TimelineEvent} against real Postgres (V77). + * Mirrors {@code MigrationIntegrationTest}'s slice setup; never H2 — only the real DB proves + * enum-as-varchar storage, the RANGE/UNKNOWN CHECK constraints, FK cascade, and {@code @Version}. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TimelineEventTest { + + @Autowired TimelineEventRepository events; + @Autowired PersonRepository persons; + @Autowired DocumentRepository documents; + @Autowired EntityManager em; + + /** + * Sensible defaults; each test overrides only what it asserts. {@code createdBy}/{@code updatedBy} + * default to random UUIDs — both columns are NOT NULL and not auto-populated, so without these + * every test would fail at flush with the same constraint violation (red for the wrong reason). + * Precision is intentionally left unset so {@code @Builder.Default YEAR} can be exercised. + */ + private TimelineEvent.TimelineEventBuilder makeEvent() { + return TimelineEvent.builder() + .title("Hochzeit von Anna und Otto") + .type(EventType.PERSONAL) + .eventDate(LocalDate.of(1914, 7, 28)) + .createdBy(UUID.randomUUID()) + .updatedBy(UUID.randomUUID()); + } + + @Test + void persists_and_loads_event_with_required_fields() { + TimelineEvent saved = events.save(makeEvent().build()); + + assertThat(saved.getId()).isNotNull(); + } + + @Test + void precision_defaults_to_YEAR_when_not_set() { + TimelineEvent saved = events.save(makeEvent().build()); + + assertThat(saved.getPrecision()).isEqualTo(DatePrecision.YEAR); + } + + @Test + void persists_event_with_linked_persons() { + Person anna = persons.save(Person.builder().firstName("Anna").lastName("Raddatz").build()); + TimelineEvent saved = events.save(makeEvent().persons(Set.of(anna)).build()); + em.flush(); + em.clear(); + + TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow(); + assertThat(reloaded.getPersons()).extracting(Person::getId).containsExactly(anna.getId()); + } + + @Test + void persists_event_with_linked_documents() { + Document letter = documents.save(Document.builder() + .title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build()); + TimelineEvent saved = events.save(makeEvent().documents(Set.of(letter)).build()); + em.flush(); + em.clear(); + + TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow(); + assertThat(reloaded.getDocuments()).extracting(Document::getId).containsExactly(letter.getId()); + } + + @Test + void eventDateEnd_round_trips_null_for_non_range() { + TimelineEvent saved = events.save(makeEvent().build()); // YEAR precision, no end + em.flush(); + em.clear(); + + assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd()).isNull(); + } + + @Test + void eventDateEnd_round_trips_value_for_range() { + TimelineEvent saved = events.save(makeEvent() + .precision(DatePrecision.RANGE) + .eventDate(LocalDate.of(1914, 1, 1)) + .eventDateEnd(LocalDate.of(1918, 12, 31)) + .build()); + em.flush(); + em.clear(); + + assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd()) + .isEqualTo(LocalDate.of(1918, 12, 31)); + } + + @Test + void description_round_trips_null() { + TimelineEvent saved = events.save(makeEvent().build()); + em.flush(); + em.clear(); + + assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isNull(); + } + + @Test + void description_round_trips_multi_kb_text() { + // Proves TEXT has no length cap — @Column(columnDefinition = "TEXT") overrides + // Hibernate's default VARCHAR(255). + String longText = "Sommertage am See. ".repeat(500); // ~9.5 KB + TimelineEvent saved = events.save(makeEvent().description(longText).build()); + em.flush(); + em.clear(); + + assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isEqualTo(longText); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void range_invariant_rejects_non_null_end_without_range_precision() { + // precision YEAR + non-null end violates chk_timeline_event_range. + try { + assertThatThrownBy(() -> events.saveAndFlush(makeEvent() + .eventDateEnd(LocalDate.of(1918, 12, 31)) + .build())) + .isInstanceOf(DataIntegrityViolationException.class); + } finally { + events.deleteAll(); // NOT_SUPPORTED opts out of the rollback; clean any leaked row + } + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void range_invariant_rejects_range_precision_without_end_date() { + // precision RANGE + null end violates chk_timeline_event_range. + try { + assertThatThrownBy(() -> events.saveAndFlush(makeEvent() + .precision(DatePrecision.RANGE) + .build())) + .isInstanceOf(DataIntegrityViolationException.class); + } finally { + events.deleteAll(); + } + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void unknown_precision_is_rejected() { + // chk_timeline_event_precision forbids UNKNOWN — curated events are never undated. + try { + assertThatThrownBy(() -> events.saveAndFlush(makeEvent() + .precision(DatePrecision.UNKNOWN) + .build())) + .isInstanceOf(DataIntegrityViolationException.class); + } finally { + events.deleteAll(); + } + } + + @Test + void version_is_null_before_persist_and_zero_after_save() { + TimelineEvent fresh = makeEvent().build(); + assertThat(fresh.getVersion()).isNull(); // @Version Long is null pre-persist + + TimelineEvent saved = events.saveAndFlush(fresh); + assertThat(saved.getVersion()).isEqualTo(0L); // Hibernate sets 0 on insert + } + + @ParameterizedTest + @EnumSource(value = DatePrecision.class, names = {"DAY", "MONTH", "SEASON", "YEAR", "APPROX"}) + void all_non_unknown_precisions_are_accepted(DatePrecision precision) { + // Accept-side of chk_timeline_event_precision: every non-RANGE, non-UNKNOWN value persists. + // Documents that SEASON ("Sommer 1914") and APPROX ("ca. 1914") are intentionally legal, + // so an over-tight CHECK cannot ship green. (RANGE is covered by the round-trip test.) + TimelineEvent saved = events.saveAndFlush(makeEvent().precision(precision).build()); + + assertThat(saved.getId()).isNotNull(); + } +} diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 8fd84f5c..e6f50f52 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -164,6 +164,12 @@ _Not to be confused with a document item's optional note_ — a document item's **Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience. +**TimelineEvent** (`TimelineEvent`, table `timeline_events`) `[internal]` — a curated event on the family timeline (*Zeitstrahl*), authored by a curator rather than OCR-derived. Carries the same date block as a `Document` (`eventDate` + `precision` + nullable `eventDateEnd`) so events and letters render through one path, plus a `title`, optional `description`, an `EventType`, and `ManyToMany` links to the `Person`s it involves and the `Document`s that support it (both join FKs `ON DELETE CASCADE`). Diverges from `Document` with an optimistic-lock `@Version` and a NOT NULL `createdBy`/`updatedBy` audit trail (bare UUIDs, no FK to `app_users`) for the multi-curator edit flow. Two DB CHECKs: `event_date_end` is non-null **iff** precision is `RANGE` (a strict biconditional, intentionally tighter than `Document`'s open-ended ranges), and `precision` is never `UNKNOWN` (a curated event always has at least a year; `SEASON`/`APPROX` stay legal). See ADR-040. + +**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040). + +**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain. + **Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table. **Audit log** (`AuditLog`, table `audit_log`) — an append-only event store recording domain-level activity (document edits, user actions, etc.). Append-only by application convention; a `REVOKE UPDATE, DELETE` is attempted at the DB layer (see migrations V46, V47) but is a no-op if the application role is the table owner in PostgreSQL. Do not rely on DB-enforced immutability — the constraint is application-layer only. diff --git a/docs/adr/040-timeline-domain-data-model.md b/docs/adr/040-timeline-domain-data-model.md new file mode 100644 index 00000000..30f5767a --- /dev/null +++ b/docs/adr/040-timeline-domain-data-model.md @@ -0,0 +1,115 @@ +# ADR-040 — Timeline domain data model + +**Status:** Accepted +**Date:** 2026-06-12 +**Issue:** #774 (Zeitstrahl milestone, foundational) + +## Context + +The Zeitstrahl (family timeline) needs a home for *curated* events — births, +weddings, moves, and world-historical context a curator types in by hand, distinct +from the OCR-derived `Document` letters. This ADR commits the new `timeline` domain's +data model: the `TimelineEvent` entity, the `EventType` enum, a repository, and the +V77 migration. No service, controller, or DTO ships here — those land in later issues. + +A `TimelineEvent` carries the same date block as `Document` (`eventDate` + +`precision` + `eventDateEnd`) so events and letters render through one path, but its +audit footprint deliberately diverges (see below). + +## Decisions + +### 1. New `timeline` domain package, separate from `geschichte` + +Curated timeline events are their own concern, not a Lesereise/`Geschichte` subtype. +The domain owns `TimelineEvent`, `EventType`, and `TimelineEventRepository`. + +### 2. Responses (#775) will be views, not serialized entities + +`TimelineEvent` has two LAZY `ManyToMany` collections (`persons`, `documents`) and +`open-in-view` is `false` — exactly the shape that motivated ADR-036 for `geschichte`. +#775 must assemble `TimelineEventView`/`TimelineEventSummary` inside the service +transaction; a serialized entity is a 500 waiting to happen. Decided up front so it is +not retrofitted later. + +### 3. `precision` reuses `document.DatePrecision` — imported, not duplicated + +The `timeline` package imports `org.raddatz.familienarchiv.document.DatePrecision` +directly, the same cross-domain value-type sharing ADR-039 established for `person`. +An enum has no behaviour and no persistence side effects, so the layering rule +(services → own repository) does not govern it. The enum stays a verbatim mirror of the +import normalizer's `Precision` values (ADR-025); changes must stay in sync with +`tools/import-normalizer/dates.py`. Moving `DatePrecision` into a shared package is a +wider refactor (touching `Document`, `importing`, `person`) and its own future ADR. + +### 4. `precision = UNKNOWN` is forbidden; every other value is legal + +`eventDate` is NOT NULL — a curated event always has at least a year, so only OCR letters +fall into the "Ohne Datum" bucket. The CHECK `chk_timeline_event_precision` +(`date_precision <> 'UNKNOWN'`) forbids exactly that one value. `SEASON` ("Sommer 1914") +and `APPROX` ("ca. 1914") are explicitly legal — family memory is full of both, and the +spec's rendering table covers them. Do not narrow the CHECK to an allow-list; an +over-tight constraint would force curators to fake `YEAR` and render dishonest dates. + +### 5. RANGE invariant is a strict biconditional at the DB, intentionally tighter than `Document` + +`chk_timeline_event_range` enforces `(date_precision = 'RANGE') = (event_date_end IS NOT +NULL)` — `eventDateEnd` is non-null **iff** precision is `RANGE`, both directions. This is +*stricter* than `Document`'s open-ended ranges (which allow a null end on a RANGE) because a +curated event always has a known, closed end when it spans a range — it is authored, not +inferred. This divergence is deliberate: a future "bug fix" must not relax it to match +`Document`. + +### 6. Audit trail: `@Version` + NOT NULL `createdBy`/`updatedBy`, diverging from `Document` + +`Document` has neither a version nor a creator. A curated entity edited by multiple curators +warrants real protection, so `TimelineEvent` adds: + +- `@Version Long version` — optimistic locking for the multi-curator edit flow (#775). + Object `Long` (not primitive) so it is `null` before first persist; Hibernate sets `0` on + insert. The service **must** catch `ObjectOptimisticLockingFailureException` and translate + it to `DomainException.conflict(...)`. Without that translation a concurrent-write conflict + surfaces as HTTP 500 with Hibernate internals in the body — information disclosure (CWE-209). +- `createdBy`/`updatedBy` as bare `UUID`, `NOT NULL`, no FK to `app_users` (sidecar pattern, + matching `DocumentAnnotation`/`OcrJob`; keeps `timeline` decoupled from `user`, avoids + lazy-load surprises in the read-heavy assembly path). NOT NULL makes a curated event with no + author impossible — an audit gap closed at the schema level. `DocumentAnnotation.createdBy` + is nullable and has no `updatedBy`; the escalation here is deliberate because curated events + are multi-author. Curator display names resolve through `UserService` at render time. + +`updatedBy` is **not** advanced by `@UpdateTimestamp` — the service must set it from the +session principal before every `save()`, or the timestamp moves while the "who" goes stale. + +### 7. `createdBy`/`updatedBy` are server-populated only — never bound from client input + +Both are set from the session principal in the service, never from a request body. Binding +them from client input is an authorship-forgery / mass-assignment vector (CWE-639). #775's +regression suite must include forgery cases on **both** write paths (`POST` body with +`createdBy`, `PUT` body with `updatedBy`) — create and update are separate binding paths, so +testing only one leaves half the vector open. The update test must assert `updatedBy` equals +the *second* editor's UUID, not merely non-null. + +### 8. `EventType` string values are a stable frontend styling contract + +The Tailwind class map in the timeline Svelte components hard-codes `PERSONAL` (family accent) +and `HISTORICAL` (muted world accent) as strings. There is no mapping layer — renaming either +value requires a coordinated frontend change. Recorded here to prevent a silent regression. + +### 9. Explicit `@JoinTable` on both ManyToMany fields + +Without explicit `@JoinTable(name, joinColumns, inverseJoinColumns)`, Hibernate's naming +strategy could diverge from the V77 DDL's explicit table/column names. Explicit mapping +guarantees alignment and makes future column renames a deliberate, visible change. All four FK +columns are `ON DELETE CASCADE`: deleting a Person or Document drops the join row and leaves +the event intact (V71/ADR-032 hardening — a person delete must never 500). + +## Consequences + +- V77 is forward-only; rollback is manual DDL (`DROP TABLE` the two join tables, then + `timeline_events`). No rollback script, no rollback test. +- The `timeline → document.DatePrecision` compile coupling is permanent until a shared-package + refactor; precedent already exists (`importing/DocumentImporter`, `person`). +- The service/controller/DTO layer (#775) inherits the view-assembly, optimistic-lock + translation, forgery-guard, and permission obligations recorded above. It must also add a + service-level title-length check (new `ErrorCode`, e.g. `TIMELINE_TITLE_TOO_LONG`, mirroring + `GESCHICHTE_TITLE_TOO_LONG`) — `title` is `VARCHAR(255)`, and without the guard an over-long + title surfaces as a raw `DataIntegrityViolationException` → HTTP 500. diff --git a/docs/architecture/c4/l3-backend-timeline.puml b/docs/architecture/c4/l3-backend-timeline.puml new file mode 100644 index 00000000..b0072429 --- /dev/null +++ b/docs/architecture/c4/l3-backend-timeline.puml @@ -0,0 +1,24 @@ +@startuml +!include + +title Component Diagram: API Backend — Timeline (Zeitstrahl) + +ContainerDb(db, "PostgreSQL", "PostgreSQL 16") + +System_Boundary(backend, "API Backend (Spring Boot)") { + Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). Issue #774 ships the repository empty; the per-person filter query lands in #777.") + + Component(timelineSvc, "TimelineEventService", "Spring Service (planned, #775)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.") + Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, #775)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).") +} + +System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters") +System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves") + +Rel(timelineRepo, db, "SQL queries", "JDBC") +Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)") +Rel(timelineCtrl, timelineSvc, "Delegates to (planned)") +Rel(timelineRepo, personDomain, "References persons via join table") +Rel(timelineRepo, documentDomain, "References documents via join table") + +@enduml diff --git a/docs/architecture/db/db-orm.puml b/docs/architecture/db/db-orm.puml index 8b54f2cc..f84d2112 100644 --- a/docs/architecture/db/db-orm.puml +++ b/docs/architecture/db/db-orm.puml @@ -1,6 +1,6 @@ @startuml db-orm -' Schema source: Flyway V1–V76 (excl. V37, V43 — intentionally removed) -' Schema as of: V76 (2026-06-12) +' Schema source: Flyway V1–V77 (excl. V37, V43 — intentionally removed) +' Schema as of: V77 (2026-06-12) ' ⚠ This is a versioned snapshot. Update when the schema changes significantly. hide circle @@ -386,6 +386,39 @@ package "Supporting" { } } +' ── Timeline (Zeitstrahl) ── +package "Timeline" { + + entity timeline_events { + id : UUID <> + -- + title : VARCHAR(255) NOT NULL + type : VARCHAR(16) NOT NULL + event_date : DATE NOT NULL + date_precision : VARCHAR(16) NOT NULL DEFAULT 'YEAR' + event_date_end : DATE + description : TEXT + created_by : UUID NOT NULL + created_at : TIMESTAMP + updated_by : UUID NOT NULL + updated_at : TIMESTAMP + version : BIGINT + == + CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL)) + CHECK (date_precision <> 'UNKNOWN') + } + + entity timeline_event_persons { + timeline_event_id : UUID <> + person_id : UUID <> + } + + entity timeline_event_documents { + timeline_event_id : UUID <> + document_id : UUID <> + } +} + ' Auth relationships app_users_groups }o--|| app_users : app_user_id app_users_groups }o--|| user_groups : group_id @@ -449,4 +482,10 @@ geschichten_persons }o--|| persons : person_id journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE) journey_items }o--o| documents : document_id (ON DELETE SET NULL) +' Timeline relationships +timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE) +timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE) +timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE) +timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE) + @enduml diff --git a/docs/architecture/db/db-relationships.puml b/docs/architecture/db/db-relationships.puml index 3695a9d6..eccc8501 100644 --- a/docs/architecture/db/db-relationships.puml +++ b/docs/architecture/db/db-relationships.puml @@ -6,6 +6,7 @@ ' precision/attribution fields); no new FK relationships, so this diagram is unchanged. ' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date + ' precision columns; columns only, no new FK relationships, diagram unchanged. +' Note: V77 adds the timeline_events table + two join tables (Timeline package below). hide circle skinparam linetype ortho @@ -71,6 +72,13 @@ package "Supporting" { entity journey_items } +' ── Timeline (Zeitstrahl) ── +package "Timeline" { + entity timeline_events + entity timeline_event_persons + entity timeline_event_documents +} + ' Auth relationships app_users_groups }o--|| app_users : app_user_id app_users_groups }o--|| user_groups : group_id @@ -136,4 +144,11 @@ journey_items }o--o| documents : document_id (ON DELETE SET NULL) note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74) note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75) +' Timeline relationships (V77) +timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE) +timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE) +timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE) +timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE) +note right of timeline_events : CHECK event_date_end non-null IFF RANGE\nCHECK date_precision <> 'UNKNOWN' (V77) + @enduml