diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java index a50679f2..e6188cdc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java @@ -5,12 +5,14 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; - +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem; import org.raddatz.familienarchiv.user.AppUser; -import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.person.Person; + import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -40,6 +42,12 @@ public class Geschichte { @Builder.Default private GeschichteStatus status = GeschichteStatus.DRAFT; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private GeschichteType type = GeschichteType.STORY; + @ManyToOne @JoinColumn(name = "author_id") private AppUser author; @@ -51,12 +59,18 @@ public class Geschichte { @Builder.Default private Set persons = new HashSet<>(); - @ManyToMany(fetch = FetchType.EAGER) - @JoinTable(name = "geschichten_documents", - joinColumns = @JoinColumn(name = "geschichte_id"), - inverseJoinColumns = @JoinColumn(name = "document_id")) + // LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE + // (application.yaml), so this collection is DEAD at Jackson serialization time unless + // explicitly initialized inside the service transaction. getById() is + // @Transactional(readOnly=true) AND calls getItems().size() to force-init before return. + // list() must NOT serialize items at all — it returns a GeschichteSummary projection. + // This is the first List ("bag") collection on Geschichte — adding a second EAGER/ + // fetch-joined List here will throw MultipleBagFetchException at boot. + @OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true, + fetch = FetchType.LAZY) + @OrderBy("position ASC") @Builder.Default - private Set documents = new HashSet<>(); + private List items = new ArrayList<>(); @CreationTimestamp @Column(updatable = false) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java new file mode 100644 index 00000000..eb3fdb10 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java @@ -0,0 +1,36 @@ +package org.raddatz.familienarchiv.geschichte; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * List-projection for the /api/geschichten grid. Never carries items — avoids + * LazyInitializationException (open-in-view: false) and prevents Cartesian joins. + * Mirrors the PersonSummaryDTO precedent. + * + *

Field set: exactly what the live grid card renders (title, author byline, body excerpt, + * publishedAt, status, type). Does NOT carry items or persons. + */ +public interface GeschichteSummary { + + UUID getId(); + + String getTitle(); + + GeschichteStatus getStatus(); + + GeschichteType getType(); + + /** Nested closed projection — exposes only the fields the grid card needs. */ + AuthorSummary getAuthor(); + + LocalDateTime getPublishedAt(); + + String getBody(); + + interface AuthorSummary { + String getFirstName(); + String getLastName(); + String getEmail(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java new file mode 100644 index 00000000..57b7fb27 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.geschichte; + +public enum GeschichteType { + STORY, + JOURNEY +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java index bd05d568..969ca6dd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java @@ -1,7 +1,6 @@ package org.raddatz.familienarchiv.geschichte; import lombok.Data; -import org.raddatz.familienarchiv.geschichte.GeschichteStatus; import java.util.List; import java.util.UUID; @@ -17,5 +16,4 @@ public class GeschichteUpdateDTO { private String body; private GeschichteStatus status; private List personIds; - private List documentIds; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java new file mode 100644 index 00000000..173423d9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java @@ -0,0 +1,51 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.geschichte.Geschichte; + +import java.util.UUID; + +@Entity +@Table(name = "journey_items") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class JourneyItem { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "geschichte_id", nullable = false) + @JsonIgnore + private Geschichte geschichte; + + // Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order + // — the editor is responsible for keeping them distinct. + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int position; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id") + @JsonIgnore + private Document document; + + // CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email + // renderer MUST escape this; only Svelte {note} is auto-safe. + @Column(columnDefinition = "TEXT") + private String note; + + // JPA uses field access — this getter is not persisted. Jackson serializes it as documentId. + // Exposing only the UUID prevents circular references and large nested payloads. + public UUID getDocumentId() { + return document != null ? document.getId() : null; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java new file mode 100644 index 00000000..5534195b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java @@ -0,0 +1,13 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface JourneyItemRepository extends JpaRepository { + + List findAllByGeschichteId(UUID geschichteId); +} diff --git a/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql b/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql new file mode 100644 index 00000000..3757e267 --- /dev/null +++ b/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql @@ -0,0 +1,73 @@ +-- Production pre-requisite — run BEFORE applying this migration: +-- docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ +-- -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"' +-- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \ +-- --table=geschichten_documents \ +-- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql' +-- Take the dump even if geschichten_documents is empty — it captures the table DEFINITION +-- for emergency reconstruction. The DROP TABLE is the only irreversible step; the +-- INSERT...SELECT is a no-op when there is no data. No DDL rollback path exists after commit. +-- +-- REVERSE PROCEDURE (if V72 must be rolled back): restore the pre-V72 dump, then re-derive +-- the junction from the new table: +-- INSERT INTO geschichten_documents (geschichte_id, document_id) +-- SELECT geschichte_id, document_id FROM journey_items WHERE document_id IS NOT NULL; +-- Note: the reconstructed junction FK is ON DELETE CASCADE per the original V58 +-- (NOT the new SET NULL of journey_items). Domain FKs target app_users (post-V60) — +-- do NOT hand-type V58's verbatim "REFERENCES users" DDL nor copy journey_items' SET NULL +-- into the reconstructed junction. +-- +-- ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order +-- existed. Ordering by meta_date is a plausible default a Lesereise lets curators +-- re-sequence. This is not a requirement; it is the best available approximation. +-- +-- ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block; +-- this block visibly degrades to generic links (loss of per-document title AND date) for ALL +-- current readers during the stub window. Accepted because the reader follow-on is the +-- next-priority blocking dependency. + +-- Step 1: Add type discriminator column to geschichten +ALTER TABLE geschichten + ADD COLUMN type VARCHAR(50) DEFAULT 'STORY' NOT NULL; + +-- Step 2: Create journey_items table +CREATE TABLE journey_items ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + geschichte_id UUID NOT NULL, + position INT NOT NULL, + document_id UUID, + note TEXT, + CONSTRAINT pk_journey_items PRIMARY KEY (id), + CONSTRAINT fk_journey_items_geschichte + FOREIGN KEY (geschichte_id) REFERENCES geschichten(id) ON DELETE CASCADE, + CONSTRAINT fk_journey_items_document + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE SET NULL, + CONSTRAINT chk_journey_item_not_empty + CHECK (document_id IS NOT NULL OR note IS NOT NULL) +); + +-- Step 3: Index for ordered retrieval by geschichte + position +CREATE INDEX idx_journey_items_geschichte_position + ON journey_items (geschichte_id, position ASC); + +-- Step 4: Migrate geschichten_documents → journey_items +-- Positions are multiples of 1000 (headroom for drag-reorder). +-- Ordered by meta_date ASC NULLS LAST, then documents.id ASC as deterministic tiebreaker. +-- SELECT DISTINCT guards against duplicate junction rows producing duplicate journey items. +INSERT INTO journey_items (id, geschichte_id, position, document_id) +SELECT + gen_random_uuid(), + gd.geschichte_id, + (ROW_NUMBER() OVER ( + PARTITION BY gd.geschichte_id + ORDER BY d.meta_date ASC NULLS LAST, d.id ASC + ) * 1000)::INT AS position, + gd.document_id +FROM ( + SELECT DISTINCT geschichte_id, document_id + FROM geschichten_documents +) gd +LEFT JOIN documents d ON d.id = gd.document_id; + +-- Step 5: Drop the old junction table (irreversible — take the pg_dump first) +DROP TABLE geschichten_documents;