feat(geschichte): add GeschichteType, JourneyItem entity, GeschichteSummary, and V72 migration
- GeschichteType enum {STORY, JOURNEY} — default STORY
- JourneyItem entity replaces geschichten_documents junction table;
position-ordered, document_id nullable (note-only items allowed),
CHECK(document_id IS NOT NULL OR note IS NOT NULL)
- GeschichteSummary interface projection for list() queries (avoids lazy-init)
- Geschichte entity gains `type` + `items` (LAZY, orphanRemoval, CascadeType.ALL)
replacing the old `documents` ManyToMany bag
- GeschichteUpdateDTO: remove documentIds (replaced by JourneyItem API)
- V72 migration: adds `type` column, creates `journey_items` table with
FK ON DELETE CASCADE (geschichte) / ON DELETE SET NULL (document),
migrates geschichten_documents ordered by meta_date, drops junction table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,14 @@ import jakarta.persistence.*;
|
|||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -40,6 +42,12 @@ public class Geschichte {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private GeschichteType type = GeschichteType.STORY;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "author_id")
|
@JoinColumn(name = "author_id")
|
||||||
private AppUser author;
|
private AppUser author;
|
||||||
@@ -51,12 +59,18 @@ public class Geschichte {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Person> persons = new HashSet<>();
|
private Set<Person> persons = new HashSet<>();
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
|
||||||
@JoinTable(name = "geschichten_documents",
|
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
|
||||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
// explicitly initialized inside the service transaction. getById() is
|
||||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
// @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
|
@Builder.Default
|
||||||
private Set<Document> documents = new HashSet<>();
|
private List<JourneyItem> items = new ArrayList<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(updatable = false)
|
@Column(updatable = false)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
public enum GeschichteType {
|
||||||
|
STORY,
|
||||||
|
JOURNEY
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -17,5 +16,4 @@ public class GeschichteUpdateDTO {
|
|||||||
private String body;
|
private String body;
|
||||||
private GeschichteStatus status;
|
private GeschichteStatus status;
|
||||||
private List<UUID> personIds;
|
private List<UUID> personIds;
|
||||||
private List<UUID> documentIds;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JourneyItem, UUID> {
|
||||||
|
|
||||||
|
List<JourneyItem> findAllByGeschichteId(UUID geschichteId);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user