Compare commits

...

11 Commits

Author SHA1 Message Date
Marcel
bde1237358 docs(adr): record title-length guard obligation for #775
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m53s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m42s
CI / fail2ban Regex (pull_request) Successful in 54s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m13s
title is VARCHAR(255) with no planned service-level check; ADR-040's
consequences listed the optimistic-lock and forgery obligations for #775 but
not this one, so an over-long title would have surfaced as a raw
DataIntegrityViolationException -> HTTP 500. Mirrors GESCHICHTE_TITLE_TOO_LONG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:47:53 +02:00
Marcel
788a804810 docs(timeline): pin relative issue ordinals to Gitea issue numbers
The issue body's milestone-relative ordinals ("issue 3", "issue 5") become
unreadable once the milestone closes. Resolved against the Zeitstrahl milestone:
issue 3 = #775 (CRUD API: service/controller/DTO), issue 5 = #777 (assembly
endpoint with the per-person filter). Mapping anchored by issue 6 = #778
(date-label helper) and issue 9 = #781 (curator forms) in #774's forward notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:47:22 +02:00
Marcel
62b96f718f fix(timeline): mark always-populated audit fields REQUIRED in OpenAPI schema
createdBy/updatedBy are NOT NULL and createdAt/updatedAt/version are Hibernate-
populated on every persisted row, so per the CLAUDE.md rule they must carry
@Schema(requiredMode = REQUIRED) like id/title/type/eventDate/precision already
do. Keeps the generated TypeScript types honest if the entity ever reaches the
OpenAPI spec (responses in #775 are planned as views, per ADR-040).

Extends the #774 task list (which named only the five domain fields) per PR #816 review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:46:45 +02:00
Marcel
6ed5151e50 fix(timeline): drop join-table indexes redundant with composite PKs
idx_timeline_event_persons_event_id and idx_timeline_event_documents_event_id
duplicated the leading column of their composite primary keys — Postgres already
serves timeline_event_id lookups from the PK index, so the extra indexes only
added write overhead. The inverse-side indexes (person_id, document_id) stay;
they cover the FK cascade path.

Deviates from the #774 task list ("all four FK columns") per PR #816 review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:46:06 +02:00
Marcel
3a7c86fc87 test(timeline): allow timeline package in entity-location ArchRule
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m9s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m42s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
The entities_reside_in_domain_packages ArchUnit rule has a hardcoded
allow-list of domain packages; add ..timeline.. so TimelineEvent passes.
CI caught this — the new domain package was not yet whitelisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
1226bd0a07 docs(timeline): register timeline domain in package tables and diagrams
Add timeline/ to the root and backend package tables, TimelineEvent to the
domain-model entity tables, TimelineEvent/EventType/Zeitstrahl to the
glossary, a new l3-backend-timeline C4 component diagram, and the
timeline_events table + two join tables (with their CHECKs and cascade FKs)
to the db-orm and db-relationships ER diagrams. Bumps the db-orm snapshot to
V77.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
00a00b2c87 docs(adr): add ADR-040 timeline domain data model
Records the architectural commitment for the timeline domain: views-not-
entities for issue 3 (ADR-036 rationale), DatePrecision import coupling
(ADR-025), the UNKNOWN-forbidden / SEASON-APPROX-legal precision contract,
the strict biconditional RANGE CHECK as a deliberate divergence from
Document, the @Version + NOT NULL audit-trail decisions, the optimistic-
lock-to-conflict translation contract (CWE-209), the server-populated-only
createdBy/updatedBy forgery guard (CWE-639), and the EventType stable
frontend styling contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
cc841a7a4c test(timeline): cover persistence, constraints, and FK cascade
@DataJpaTest against real Postgres (never H2): required-field round-trip,
YEAR default, linked persons/documents, eventDateEnd null/range round-trip,
TEXT description with no length cap, both RANGE-invariant rejections, the
UNKNOWN-precision rejection (NOT_SUPPORTED so the constraint violation does
not poison the test transaction), version null-before-persist/0-after-save,
and a parameterized accept-side proving DAY/MONTH/SEASON/YEAR/APPROX all
persist. makeEvent() defaults createdBy/updatedBy to random UUIDs so every
red is red for the intended reason.

@SpringBootTest cascade guard: deleting a linked Person/Document via the
domain service drops the join row (verified by direct COUNT) and leaves the
event intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
513cdb7a4d feat(timeline): add V77 migration for timeline_events table
Creates timeline_events plus the timeline_event_persons and
timeline_event_documents join tables, all FK columns ON DELETE CASCADE
(a person/document delete drops the join row, the event survives —
V71-class hardening). Two CHECK constraints push integrity to Postgres:
chk_timeline_event_range enforces event_date_end non-null IFF RANGE (a
strict biconditional, intentionally tighter than Document's open-ended
ranges), and chk_timeline_event_precision forbids exactly UNKNOWN while
keeping SEASON/APPROX legal. FK and query-column indexes added up-front
to avoid the V62 retrofit debt. Forward-only, additive DDL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
595007213c feat(timeline): add TimelineEvent entity and repository
Curated timeline event mirroring Document's date block (eventDate /
precision / eventDateEnd) so events and letters share one rendering path.
Audit footprint deliberately diverges from Document: @Version optimistic
lock plus NOT NULL createdBy/updatedBy for the multi-curator edit flow.
precision reuses document.DatePrecision (imported, not duplicated) and
defaults to YEAR. ManyToMany persons/documents with explicit @JoinTable +
@BatchSize, matching Document's join conventions.

Repository is empty for now with a TODO marker for the issue-5 per-person
filter query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
45001f042a feat(timeline): add EventType enum
PERSONAL/HISTORICAL classify a curated timeline event. The string value
names are a stable frontend styling contract (family vs. muted world
accent) — no mapping layer; renaming requires a coordinated frontend
change. First piece of the new timeline domain (Zeitstrahl, issue #774).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
14 changed files with 728 additions and 3 deletions

View File

@@ -95,6 +95,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain │ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain ├── tag/ Tag domain
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
└── user/ User domain — AppUser, UserGroup, UserService └── user/ User domain — AppUser, UserGroup, UserService
``` ```
@@ -115,6 +116,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` | | `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) | | `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` | | `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` **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`

View File

@@ -42,6 +42,7 @@ src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ # PersonRelationship sub-domain │ └── relationship/ # PersonRelationship sub-domain
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ # Tag domain — Tag, TagService, TagController ├── tag/ # Tag domain — Tag, TagService, TagController
├── timeline/ # Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
└── user/ # User domain — AppUser, UserGroup, UserService └── 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 | | `Comment` | `document_comments` | Threaded comments with mentions |
| `Notification` | `notifications` | User notification feed | | `Notification` | `notifications` | User notification feed |
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking | | `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` **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`

View File

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

View File

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

View File

@@ -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<TimelineEvent, UUID> {
// TODO(#777): findByPersonsContaining(Person) needed for the per-person filter
}

View File

@@ -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);

View File

@@ -122,7 +122,8 @@ class ArchitectureTest {
.that().areAnnotatedWith(Entity.class) .that().areAnnotatedWith(Entity.class)
.should().resideInAnyPackage( .should().resideInAnyPackage(
"..document..", "..person..", "..tag..", "..user..", "..document..", "..person..", "..tag..", "..user..",
"..geschichte..", "..notification..", "..ocr..", "..audit.." "..geschichte..", "..notification..", "..ocr..", "..audit..",
"..timeline.."
); );
// TODO Rule 5: Controllers expose endpoints under their domain prefix // TODO Rule 5: Controllers expose endpoints under their domain prefix

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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. **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. **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. **Audit log** (`AuditLog`, table `audit_log`) — an append-only event store recording domain-level activity (document edits, user actions, etc.). Append-only by application convention; a `REVOKE UPDATE, DELETE` is attempted at the DB layer (see migrations V46, V47) but is a no-op if the application role is the table owner in PostgreSQL. Do not rely on DB-enforced immutability — the constraint is application-layer only.

View File

@@ -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.

View File

@@ -0,0 +1,24 @@
@startuml
!include <C4/C4_Component>
title Component Diagram: API Backend — Timeline (Zeitstrahl)
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). 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

View File

@@ -1,6 +1,6 @@
@startuml db-orm @startuml db-orm
' Schema source: Flyway V1V76 (excl. V37, V43 — intentionally removed) ' Schema source: Flyway V1V77 (excl. V37, V43 — intentionally removed)
' Schema as of: V76 (2026-06-12) ' Schema as of: V77 (2026-06-12)
' ⚠ This is a versioned snapshot. Update when the schema changes significantly. ' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
hide circle hide circle
@@ -386,6 +386,39 @@ package "Supporting" {
} }
} }
' ── Timeline (Zeitstrahl) ──
package "Timeline" {
entity timeline_events {
id : UUID <<PK>>
--
title : VARCHAR(255) NOT NULL
type : VARCHAR(16) NOT NULL
event_date : DATE NOT NULL
date_precision : VARCHAR(16) NOT NULL DEFAULT 'YEAR'
event_date_end : DATE
description : TEXT
created_by : UUID NOT NULL
created_at : TIMESTAMP
updated_by : UUID NOT NULL
updated_at : TIMESTAMP
version : BIGINT
==
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL))
CHECK (date_precision <> 'UNKNOWN')
}
entity timeline_event_persons {
timeline_event_id : UUID <<FK>>
person_id : UUID <<FK>>
}
entity timeline_event_documents {
timeline_event_id : UUID <<FK>>
document_id : UUID <<FK>>
}
}
' Auth relationships ' Auth relationships
app_users_groups }o--|| app_users : app_user_id app_users_groups }o--|| app_users : app_user_id
app_users_groups }o--|| user_groups : group_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--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL) 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 @enduml

View File

@@ -6,6 +6,7 @@
' precision/attribution fields); no new FK relationships, so this diagram is unchanged. ' 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 + ' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
' precision columns; columns only, no new FK relationships, diagram unchanged. ' 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 hide circle
skinparam linetype ortho skinparam linetype ortho
@@ -71,6 +72,13 @@ package "Supporting" {
entity journey_items entity journey_items
} }
' ── Timeline (Zeitstrahl) ──
package "Timeline" {
entity timeline_events
entity timeline_event_persons
entity timeline_event_documents
}
' Auth relationships ' Auth relationships
app_users_groups }o--|| app_users : app_user_id app_users_groups }o--|| app_users : app_user_id
app_users_groups }o--|| user_groups : group_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 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) 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 @enduml