diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEvent.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEvent.java new file mode 100644 index 00000000..86155500 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEvent.java @@ -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. + * + *

The date block ({@link #eventDate}, {@link #precision}, {@link #eventDateEnd}) mirrors + * {@code Document}'s so events and letters share one rendering path. The mirror applies to + * the date block only — the audit footprint deliberately diverges (see ADR-040). + */ +@Entity +@Table(name = "timeline_events") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TimelineEvent { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private EventType type; + + /** The most precise date known for the event. Always present — a curated event is never undated. */ + @Column(name = "event_date", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDate eventDate; + + /** + * Precision of {@link #eventDate}. Reuses {@code document.DatePrecision} (one rendering + * path; see ADR-025 / ADR-040). Every value except {@code UNKNOWN} is legal for a curated + * event — including {@code SEASON} ("Sommer 1914") and {@code APPROX} ("ca. 1914"). The DB + * CHECK forbids exactly {@code UNKNOWN}; do not narrow it further. + */ + @Enumerated(EnumType.STRING) + @Column(name = "date_precision", nullable = false, length = 16) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private DatePrecision precision = DatePrecision.YEAR; + + /** Range end — non-null iff {@link #precision} is {@code RANGE} (DB CHECK, both directions). */ + @Column(name = "event_date_end") + private LocalDate eventDateEnd; + + @Column(columnDefinition = "TEXT") + private String description; + + /** People the event involves. */ + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "timeline_event_persons", + joinColumns = @JoinColumn(name = "timeline_event_id"), + inverseJoinColumns = @JoinColumn(name = "person_id")) + @BatchSize(size = 50) + @Builder.Default + private Set persons = new HashSet<>(); + + /** Optional supporting letters linked to the event. */ + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "timeline_event_documents", + joinColumns = @JoinColumn(name = "timeline_event_id"), + inverseJoinColumns = @JoinColumn(name = "document_id")) + @BatchSize(size = 50) + @Builder.Default + private Set documents = new HashSet<>(); + + /** + * UUID of the {@code AppUser} who created the event. Bare UUID, no FK to {@code app_users} + * (sidecar pattern — keeps {@code timeline} decoupled from {@code user}). Server-populated + * from the session principal; never accepted from client input (authorship-forgery vector, + * CWE-639 — see ADR-040). + */ + @Column(name = "created_by", nullable = false) + 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}; must be set before every + * {@code save()} — {@code @UpdateTimestamp} on {@link #updatedAt} does NOT set this + * automatically, so without an explicit set the timestamp advances while the "who" goes + * stale. Same forgery rationale as {@link #createdBy}. + */ + @Column(name = "updated_by", nullable = false) + 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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRepository.java new file mode 100644 index 00000000..524d7482 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRepository.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.timeline; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface TimelineEventRepository extends JpaRepository { + // TODO(issue 5): findByPersonsContaining(Person) needed for the per-person filter +}