Compare commits
7 Commits
main
...
bed99ff3fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bed99ff3fe | ||
|
|
27bfa7acbd | ||
|
|
2d9047a56b | ||
|
|
8bc21d8680 | ||
|
|
23e53bf30b | ||
|
|
cec4ee4415 | ||
|
|
50b6f807fc |
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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)
|
||||||
|
private UUID createdBy;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
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)
|
||||||
|
private UUID updatedBy;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic-lock version for the multi-curator edit flow (issue 3). 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
|
||||||
|
private Long version;
|
||||||
|
}
|
||||||
@@ -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(issue 5): findByPersonsContaining(Person) needed for the per-person filter
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- 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
|
||||||
|
-- explicit indexes on all four FK columns.
|
||||||
|
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_persons_event_id ON timeline_event_persons (timeline_event_id);
|
||||||
|
CREATE INDEX idx_timeline_event_documents_document_id ON timeline_event_documents (document_id);
|
||||||
|
CREATE INDEX idx_timeline_event_documents_event_id ON timeline_event_documents (timeline_event_id);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
112
docs/adr/040-timeline-domain-data-model.md
Normal file
112
docs/adr/040-timeline-domain-data-model.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 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 (issue 3) 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`.
|
||||||
|
Issue 3 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 (issue 3).
|
||||||
|
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). Issue 3'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 (issue 3) inherits the view-assembly, optimistic-lock
|
||||||
|
translation, forgery-guard, and permission obligations recorded above.
|
||||||
24
docs/architecture/c4/l3-backend-timeline.puml
Normal file
24
docs/architecture/c4/l3-backend-timeline.puml
Normal 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 a later issue.")
|
||||||
|
|
||||||
|
Component(timelineSvc, "TimelineEventService", "Spring Service (planned, issue 3)", "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, issue 3)", "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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@startuml db-orm
|
@startuml db-orm
|
||||||
' Schema source: Flyway V1–V76 (excl. V37, V43 — intentionally removed)
|
' Schema source: Flyway V1–V77 (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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user