From df6175ed2c878d1e0dc56afdad4913c9e097e6cd Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:02:27 +0200 Subject: [PATCH 01/57] feat(stammbaum): add V54 migration for family network Adds persons.family_member flag and person_relationships table with ON DELETE CASCADE on both FKs, no_self_rel check, unique_rel composite, indexes on both person columns, and partial unique index for symmetric SIBLING_OF pairs (LEAST/GREATEST trick). Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- .../db/migration/V54__add_family_network.sql | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V54__add_family_network.sql diff --git a/backend/src/main/resources/db/migration/V54__add_family_network.sql b/backend/src/main/resources/db/migration/V54__add_family_network.sql new file mode 100644 index 00000000..0de01f91 --- /dev/null +++ b/backend/src/main/resources/db/migration/V54__add_family_network.sql @@ -0,0 +1,30 @@ +-- Family network: marks a Person as a tree node and stores typed relationships +-- between two persons. The tree page (/stammbaum) only shows persons with +-- family_member = TRUE. Symmetric types (SPOUSE_OF, SIBLING_OF) are stored once; +-- the partial unique index keeps SIBLING_OF pairs from being duplicated in the +-- reverse direction. + +ALTER TABLE persons + ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE person_relationships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + relation_type VARCHAR(30) NOT NULL, + from_year INTEGER, + to_year INTEGER, + notes VARCHAR(2000), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT no_self_rel CHECK (person_id <> related_person_id), + CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type) +); + +CREATE INDEX idx_person_rel_person_id ON person_relationships(person_id); +CREATE INDEX idx_person_rel_related_person_id ON person_relationships(related_person_id); + +-- Symmetric SIBLING_OF: enforce only one row per unordered pair. +CREATE UNIQUE INDEX unique_sibling_pair ON person_relationships ( + LEAST(person_id, related_person_id), + GREATEST(person_id, related_person_id) +) WHERE relation_type = 'SIBLING_OF'; -- 2.49.1 From 25f62ce93bc193156fa00ed550eecf0cc01cc5c0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:05:49 +0200 Subject: [PATCH 02/57] feat(stammbaum): add backend data layer for family network - RelationType enum (9 values), PersonRelationship entity with @ToString(exclude = "notes") and LAZY person FKs. - PersonRelationshipRepository with the network bulk fetch, the per-person subgraph fetch, and the existsBy check for the circular PARENT_OF guard. - Six DTO records: CreateRelationshipRequest, RelationshipDTO, PersonNodeDTO, NetworkDTO, InferredRelationshipDTO, InferredRelationshipWithPersonDTO. @Schema(REQUIRED) on every always-populated field so OpenAPI/TS codegen stays accurate. - Person entity gains familyMember, PersonSummaryDTO gains isFamilyMember, both PersonRepository projections select p.family_member. - Three new ErrorCodes: RELATIONSHIP_NOT_FOUND, CIRCULAR_RELATIONSHIP, DUPLICATE_RELATIONSHIP. Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/dto/PersonSummaryDTO.java | 1 + .../familienarchiv/exception/ErrorCode.java | 8 +++ .../raddatz/familienarchiv/model/Person.java | 5 ++ .../relationship/PersonRelationship.java | 55 +++++++++++++++++++ .../PersonRelationshipRepository.java | 43 +++++++++++++++ .../relationship/RelationType.java | 20 +++++++ .../dto/CreateRelationshipRequest.java | 20 +++++++ .../dto/InferredRelationshipDTO.java | 14 +++++ .../InferredRelationshipWithPersonDTO.java | 14 +++++ .../relationship/dto/NetworkDTO.java | 11 ++++ .../relationship/dto/PersonNodeDTO.java | 14 +++++ .../relationship/dto/RelationshipDTO.java | 24 ++++++++ .../repository/PersonRepository.java | 4 +- 13 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationship.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationshipRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationType.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/CreateRelationshipRequest.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/InferredRelationshipDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/InferredRelationshipWithPersonDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/NetworkDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/PersonNodeDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java index 1f71045a..882821c3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java @@ -17,6 +17,7 @@ public interface PersonSummaryDTO { Integer getBirthYear(); Integer getDeathYear(); String getNotes(); + boolean isFamilyMember(); long getDocumentCount(); default String getDisplayName() { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 0db1d92d..6d4e2533 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -96,6 +96,14 @@ public enum ErrorCode { /** Internal inconsistency: expected training run row was not found after creation. 500 */ OCR_TRAINING_CONFLICT, + // --- Relationships (Stammbaum) --- + /** A relationship row with the given ID does not exist. 404 */ + RELATIONSHIP_NOT_FOUND, + /** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */ + CIRCULAR_RELATIONSHIP, + /** A relationship with the same (person, relatedPerson, type) already exists. 409 */ + DUPLICATE_RELATIONSHIP, + // --- Tags --- /** A tag with the given ID does not exist. 404 */ TAG_NOT_FOUND, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java index e731a78a..8115838a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java @@ -47,6 +47,11 @@ public class Person { private Integer birthYear; private Integer deathYear; + @Column(name = "family_member", nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private boolean familyMember = false; + // Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText). // Uses entity relationship rather than cross-domain repository access, avoiding a // separate DB roundtrip while respecting domain boundaries. diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationship.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationship.java new file mode 100644 index 00000000..9e742402 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationship.java @@ -0,0 +1,55 @@ +package org.raddatz.familienarchiv.relationship; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.raddatz.familienarchiv.model.Person; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "person_relationships") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString(exclude = "notes") +public class PersonRelationship { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "person_id", nullable = false) + @JsonIgnore + private Person person; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "related_person_id", nullable = false) + @JsonIgnore + private Person relatedPerson; + + @Enumerated(EnumType.STRING) + @Column(name = "relation_type", nullable = false, length = 30) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private RelationType relationType; + + @Column(name = "from_year") + private Integer fromYear; + + @Column(name = "to_year") + private Integer toYear; + + @Column(length = 2000) + private String notes; + + @CreationTimestamp + @Column(name = "created_at", updatable = false, nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Instant createdAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationshipRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationshipRepository.java new file mode 100644 index 00000000..14a70fd3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationshipRepository.java @@ -0,0 +1,43 @@ +package org.raddatz.familienarchiv.relationship; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Repository +public interface PersonRelationshipRepository extends JpaRepository { + + /** + * Bulk fetch for the network endpoint — pulls only edges of the given types. + * The service filters by family_member afterwards. + */ + List findAllByRelationTypeIn(Collection types); + + /** Used for the circular-PARENT_OF check in {@code addRelationship}. */ + boolean existsByPersonIdAndRelatedPersonIdAndRelationType( + UUID personId, UUID relatedPersonId, RelationType relationType); + + /** + * All edges incident on {@code personId} (either side) restricted to the given types. + * Used by the inference service to load a person's local subgraph for BFS. + */ + @Query("SELECT r FROM PersonRelationship r " + + "WHERE (r.person.id = :personId OR r.relatedPerson.id = :personId) " + + "AND r.relationType IN :types") + List findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn( + @Param("personId") UUID personId, + @Param("types") Collection types); + + /** + * All edges incident on {@code personId} (either side), all types. + * Used by the "direct relationships" listings (person edit, side panel). + */ + @Query("SELECT r FROM PersonRelationship r " + + "WHERE r.person.id = :personId OR r.relatedPerson.id = :personId") + List findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationType.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationType.java new file mode 100644 index 00000000..1846181f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationType.java @@ -0,0 +1,20 @@ +package org.raddatz.familienarchiv.relationship; + +/** + * Family-network relationship taxonomy. + * + *

Symmetric types ({@link #SPOUSE_OF}, {@link #SIBLING_OF}) are stored once; + * the inference service walks them in both directions. {@link #PARENT_OF} is + * directional: A PARENT_OF B means A is the parent. + */ +public enum RelationType { + PARENT_OF, + SPOUSE_OF, + SIBLING_OF, + FRIEND, + COLLEAGUE, + EMPLOYER, + DOCTOR, + NEIGHBOR, + OTHER +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/CreateRelationshipRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/CreateRelationshipRequest.java new file mode 100644 index 00000000..3bc7d712 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/CreateRelationshipRequest.java @@ -0,0 +1,20 @@ +package org.raddatz.familienarchiv.relationship.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.UUID; + +/** + * POST body for {@code /api/persons/{id}/relationships}. {@code relationType} + * is a string here; the controller validates it against the {@code RelationType} + * enum at the boundary. + */ +public record CreateRelationshipRequest( + @NotNull UUID relatedPersonId, + @NotBlank String relationType, + Integer fromYear, + Integer toYear, + @Size(max = 2000) String notes +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/InferredRelationshipDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/InferredRelationshipDTO.java new file mode 100644 index 00000000..4306c1ef --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/InferredRelationshipDTO.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.relationship.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Pairwise inferred relationship for the document badge. + * {@code labelFromA} reads "Person B, from A's point of view" and vice-versa + * (e.g. A=parent, B=child → labelFromA = "Sohn", labelFromB = "Vater"). + */ +public record InferredRelationshipDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromA, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromB, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/InferredRelationshipWithPersonDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/InferredRelationshipWithPersonDTO.java new file mode 100644 index 00000000..3d2e3da8 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/InferredRelationshipWithPersonDTO.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.relationship.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Used by {@code GET /api/persons/{id}/inferred-relationships}: each entry + * is a derived relationship to another family member, labelled from the + * requesting person's perspective. + */ +public record InferredRelationshipWithPersonDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) PersonNodeDTO person, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String label, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/NetworkDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/NetworkDTO.java new file mode 100644 index 00000000..123d8e27 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/NetworkDTO.java @@ -0,0 +1,11 @@ +package org.raddatz.familienarchiv.relationship.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** Payload for {@code GET /api/network}. Nodes are family members; edges are family-graph relationships. */ +public record NetworkDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List nodes, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List edges +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/PersonNodeDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/PersonNodeDTO.java new file mode 100644 index 00000000..ba712ae2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/PersonNodeDTO.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.relationship.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +/** Lightweight node for the Stammbaum tree and inferred-relationship payloads. */ +public record PersonNodeDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName, + Integer birthYear, + Integer deathYear, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java new file mode 100644 index 00000000..82792ea0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java @@ -0,0 +1,24 @@ +package org.raddatz.familienarchiv.relationship.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.raddatz.familienarchiv.relationship.RelationType; + +import java.util.UUID; + +/** + * Wire shape for a stored relationship row. Carries enough context for the + * frontend to render a chip (type), a name (relatedPersonDisplayName), a year + * range, and a delete action (id). + */ +public record RelationshipDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName, + Integer relatedPersonBirthYear, + Integer relatedPersonDeathYear, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType, + Integer fromYear, + Integer toYear, + String notes +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index 782dde24..6138510f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -38,6 +38,7 @@ public interface PersonRepository extends JpaRepository { SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, p.person_type AS personType, p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + p.family_member AS familyMember, (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount FROM persons p @@ -50,6 +51,7 @@ public interface PersonRepository extends JpaRepository { SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, p.person_type AS personType, p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + p.family_member AS familyMember, (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount FROM persons p @@ -58,7 +60,7 @@ public interface PersonRepository extends JpaRepository { OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%')) - GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes + GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member ORDER BY p.last_name ASC, p.first_name ASC """, nativeQuery = true) -- 2.49.1 From acea4a60f2cc8d55fbb22a6a8537690e2099ef2e Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:12:51 +0200 Subject: [PATCH 03/57] feat(stammbaum): inference service with BFS + LABEL_MAP (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelationToken enum (UP/DOWN/SPOUSE/SIBLING) with reverse(), and RelationshipInferenceService with: - Bidirectional adjacency map: PARENT_OF emits UP and DOWN, SPOUSE_OF and SIBLING_OF both directions. - Virtual SIBLING edges derived from shared parents — no SIBLING_OF row required for siblings to appear. - BFS with MAX_DEPTH=8. - 17-entry LABEL_MAP covering parent, child, spouse, sibling, grand*, great-grand*, uncle/aunt, niece/nephew, great-uncle/aunt, great-niece/ nephew, in-law parent/child, sibling-in-law (both paths), cousin_1. - "distant" fallback for any path not in LABEL_MAP. - Two-sided labels via path reversal. 18 unit tests written first against a stub; all 18 confirmed red, then green after implementation. PersonControllerTest's anonymous DTO updated for the new isFamilyMember() projection. Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- .../relationship/RelationToken.java | 24 ++ .../RelationshipInferenceService.java | 208 +++++++++++ .../controller/PersonControllerTest.java | 1 + .../RelationshipInferenceServiceTest.java | 337 ++++++++++++++++++ 4 files changed, 570 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationToken.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationToken.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationToken.java new file mode 100644 index 00000000..fb123589 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationToken.java @@ -0,0 +1,24 @@ +package org.raddatz.familienarchiv.relationship; + +/** + * Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}. + * A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}. + * + *

Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric + * tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched. + */ +public enum RelationToken { + UP, + DOWN, + SPOUSE, + SIBLING; + + public RelationToken reverse() { + return switch (this) { + case UP -> DOWN; + case DOWN -> UP; + case SPOUSE -> SPOUSE; + case SIBLING -> SIBLING; + }; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java new file mode 100644 index 00000000..b2ef53c3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java @@ -0,0 +1,208 @@ +package org.raddatz.familienarchiv.relationship; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO; +import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO; +import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO; +import org.raddatz.familienarchiv.repository.PersonRepository; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * Derives indirect family relationships by BFS over the family-graph subset + * (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are + * not consulted. Siblings are also derived from shared parents — no SIBLING_OF + * row is required. + */ +@Service +@RequiredArgsConstructor +public class RelationshipInferenceService { + + static final int MAX_DEPTH = 8; + + /** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */ + static final String LABEL_DISTANT = "distant"; + + private static final Map, String> LABEL_MAP = buildLabelMap(); + + private final PersonRelationshipRepository relationshipRepository; + private final PersonRepository personRepository; + + private static Map, String> buildLabelMap() { + Map, String> m = new HashMap<>(); + m.put(List.of(RelationToken.UP), "parent"); + m.put(List.of(RelationToken.DOWN), "child"); + m.put(List.of(RelationToken.SPOUSE), "spouse"); + m.put(List.of(RelationToken.SIBLING), "sibling"); + m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent"); + m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild"); + m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent"); + m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild"); + m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt"); + m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew"); + m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt"); + m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew"); + m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent"); + m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child"); + m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw"); + m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw"); + m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1"); + return Collections.unmodifiableMap(m); + } + + /** + * Shortest token path from {@code from} to {@code to}, or empty if unreachable + * within {@link #MAX_DEPTH} hops. Package-private to permit direct path + * assertions in unit tests. + */ + Optional> findShortestPath(UUID from, UUID to) { + if (from.equals(to)) return Optional.empty(); + Map> adj = buildAdjacency(); + return bfs(adj, from, to); + } + + /** Two-sided label between A and B. {@code labelFromA} reads "B is my ". */ + public Optional infer(UUID a, UUID b) { + Optional> aToB = findShortestPath(a, b); + if (aToB.isEmpty()) return Optional.empty(); + List path = aToB.get(); + return Optional.of(new InferredRelationshipDTO( + labelFor(path), + labelFor(reversePath(path)), + path.size())); + } + + /** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */ + public List findAllFor(UUID personId) { + Map> adj = buildAdjacency(); + Map> shortestPaths = bfsAll(adj, personId); + shortestPaths.remove(personId); + if (shortestPaths.isEmpty()) return List.of(); + + List ids = new ArrayList<>(shortestPaths.keySet()); + Map byId = new HashMap<>(); + for (Person p : personRepository.findAllById(ids)) { + byId.put(p.getId(), p); + } + + List out = new ArrayList<>(); + for (UUID id : ids) { + Person p = byId.get(id); + if (p == null) continue; + List path = shortestPaths.get(id); + PersonNodeDTO node = new PersonNodeDTO( + p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember()); + out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size())); + } + out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops) + .thenComparing(d -> d.person().displayName())); + return out; + } + + static String labelFor(List path) { + String specific = LABEL_MAP.get(path); + return specific != null ? specific : LABEL_DISTANT; + } + + private static List reversePath(List path) { + List reversed = new ArrayList<>(path.size()); + for (int i = path.size() - 1; i >= 0; i--) { + reversed.add(path.get(i).reverse()); + } + return List.copyOf(reversed); + } + + private Map> buildAdjacency() { + List edges = relationshipRepository.findAllByRelationTypeIn( + List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF)); + Map> adj = new HashMap<>(); + Map> parentToChildren = new HashMap<>(); + + for (PersonRelationship e : edges) { + UUID a = e.getPerson().getId(); + UUID b = e.getRelatedPerson().getId(); + switch (e.getRelationType()) { + case PARENT_OF -> { + addEdge(adj, a, b, RelationToken.DOWN); + addEdge(adj, b, a, RelationToken.UP); + parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b); + } + case SPOUSE_OF -> { + addEdge(adj, a, b, RelationToken.SPOUSE); + addEdge(adj, b, a, RelationToken.SPOUSE); + } + case SIBLING_OF -> { + addEdge(adj, a, b, RelationToken.SIBLING); + addEdge(adj, b, a, RelationToken.SIBLING); + } + default -> { /* family graph excludes other types */ } + } + } + + for (List children : parentToChildren.values()) { + for (int i = 0; i < children.size(); i++) { + for (int j = i + 1; j < children.size(); j++) { + UUID c1 = children.get(i); + UUID c2 = children.get(j); + addEdge(adj, c1, c2, RelationToken.SIBLING); + addEdge(adj, c2, c1, RelationToken.SIBLING); + } + } + } + return adj; + } + + private static void addEdge(Map> adj, UUID from, UUID to, RelationToken token) { + adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token)); + } + + private static Optional> bfs(Map> adj, UUID from, UUID to) { + Map> shortest = new HashMap<>(); + shortest.put(from, List.of()); + Deque queue = new ArrayDeque<>(); + queue.add(from); + while (!queue.isEmpty()) { + UUID curr = queue.poll(); + List currPath = shortest.get(curr); + if (currPath.size() >= MAX_DEPTH) continue; + for (Edge e : adj.getOrDefault(curr, List.of())) { + if (shortest.containsKey(e.target())) continue; + List nextPath = append(currPath, e.token()); + shortest.put(e.target(), nextPath); + if (e.target().equals(to)) return Optional.of(nextPath); + queue.add(e.target()); + } + } + return Optional.empty(); + } + + private static Map> bfsAll(Map> adj, UUID from) { + Map> shortest = new HashMap<>(); + shortest.put(from, List.of()); + Deque queue = new ArrayDeque<>(); + queue.add(from); + while (!queue.isEmpty()) { + UUID curr = queue.poll(); + List currPath = shortest.get(curr); + if (currPath.size() >= MAX_DEPTH) continue; + for (Edge e : adj.getOrDefault(curr, List.of())) { + if (shortest.containsKey(e.target())) continue; + List nextPath = append(currPath, e.token()); + shortest.put(e.target(), nextPath); + queue.add(e.target()); + } + } + return shortest; + } + + private static List append(List prefix, RelationToken next) { + List out = new ArrayList<>(prefix.size() + 1); + out.addAll(prefix); + out.add(next); + return List.copyOf(out); + } + + private record Edge(UUID target, RelationToken token) {} +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index 8539da8b..e31e2ad0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -85,6 +85,7 @@ class PersonControllerTest { public Integer getBirthYear() { return null; } public Integer getDeathYear() { return null; } public String getNotes() { return null; } + public boolean isFamilyMember() { return false; } public long getDocumentCount() { return 0; } }; } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java new file mode 100644 index 00000000..894f0ceb --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java @@ -0,0 +1,337 @@ +package org.raddatz.familienarchiv.relationship; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO; +import org.raddatz.familienarchiv.repository.PersonRepository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.when; +import static org.raddatz.familienarchiv.relationship.RelationToken.*; +import static org.raddatz.familienarchiv.relationship.RelationType.*; + +/** + * Felix Brandt — TDD red phase for RelationshipInferenceService. + *

+ * 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires + * a small graph through the mocked repository and asserts the exact abstract + * token sequence emitted by BFS — except {@code distant_label_for_long_chain} + * which asserts the fallback label, and {@code returns_empty_when_no_path} + * which asserts no result. + */ +@ExtendWith(MockitoExtension.class) +class RelationshipInferenceServiceTest { + + @Mock PersonRelationshipRepository relationshipRepository; + @Mock PersonRepository personRepository; + @InjectMocks RelationshipInferenceService service; + + // --- 1: parent --- + @Test + void parent_path_emits_UP() { + Person parent = person(); + Person child = person(); + givenEdges(parentOf(parent, child)); + + assertThat(service.findShortestPath(child.getId(), parent.getId())) + .hasValue(List.of(UP)); + } + + // --- 2: child --- + @Test + void child_path_emits_DOWN() { + Person parent = person(); + Person child = person(); + givenEdges(parentOf(parent, child)); + + assertThat(service.findShortestPath(parent.getId(), child.getId())) + .hasValue(List.of(DOWN)); + } + + // --- 3: spouse --- + @Test + void spouse_path_emits_SPOUSE() { + Person a = person(); + Person b = person(); + givenEdges(spouseOf(a, b)); + + assertThat(service.findShortestPath(a.getId(), b.getId())) + .hasValue(List.of(SPOUSE)); + } + + // --- 4: sibling --- + @Test + void sibling_path_emits_SIBLING() { + Person a = person(); + Person b = person(); + givenEdges(siblingOf(a, b)); + + assertThat(service.findShortestPath(a.getId(), b.getId())) + .hasValue(List.of(SIBLING)); + } + + // --- 5: grandparent (UP, UP) --- + @Test + void grandparent_path_emits_UP_UP() { + Person grandparent = person(); + Person parent = person(); + Person grandchild = person(); + givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild)); + + assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId())) + .hasValue(List.of(UP, UP)); + } + + // --- 6: grandchild (DOWN, DOWN) --- + @Test + void grandchild_path_emits_DOWN_DOWN() { + Person grandparent = person(); + Person parent = person(); + Person grandchild = person(); + givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild)); + + assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId())) + .hasValue(List.of(DOWN, DOWN)); + } + + // --- 7: great-grandparent (UP, UP, UP) --- + @Test + void great_grandparent_path_emits_UP_UP_UP() { + Person g = person(); + Person p = person(); + Person c = person(); + Person gc = person(); + givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc)); + + assertThat(service.findShortestPath(gc.getId(), g.getId())) + .hasValue(List.of(UP, UP, UP)); + } + + // --- 8: great-grandchild (DOWN, DOWN, DOWN) --- + @Test + void great_grandchild_path_emits_DOWN_DOWN_DOWN() { + Person g = person(); + Person p = person(); + Person c = person(); + Person gc = person(); + givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc)); + + assertThat(service.findShortestPath(g.getId(), gc.getId())) + .hasValue(List.of(DOWN, DOWN, DOWN)); + } + + // --- 9: uncle/aunt (UP, SIBLING) --- + @Test + void uncle_aunt_path_emits_UP_SIBLING() { + Person grandparent = person(); + Person parent = person(); + Person uncle = person(); + Person me = person(); + // grandparent has two children: parent and uncle. me is parent's child. + givenEdges( + parentOf(grandparent, parent), + parentOf(grandparent, uncle), + parentOf(parent, me)); + + assertThat(service.findShortestPath(me.getId(), uncle.getId())) + .hasValue(List.of(UP, SIBLING)); + } + + // --- 10: niece/nephew (SIBLING, DOWN) --- + @Test + void niece_nephew_path_emits_SIBLING_DOWN() { + Person grandparent = person(); + Person uncle = person(); + Person sibling = person(); + Person niece = person(); + // grandparent has uncle + sibling; sibling has niece. + givenEdges( + parentOf(grandparent, uncle), + parentOf(grandparent, sibling), + parentOf(sibling, niece)); + + assertThat(service.findShortestPath(uncle.getId(), niece.getId())) + .hasValue(List.of(SIBLING, DOWN)); + } + + // --- 11: great uncle/aunt (UP, UP, SIBLING) --- + @Test + void great_uncle_aunt_path_emits_UP_UP_SIBLING() { + Person ggp = person(); + Person grandparent = person(); + Person greatUncle = person(); + Person parent = person(); + Person me = person(); + givenEdges( + parentOf(ggp, grandparent), + parentOf(ggp, greatUncle), + parentOf(grandparent, parent), + parentOf(parent, me)); + + assertThat(service.findShortestPath(me.getId(), greatUncle.getId())) + .hasValue(List.of(UP, UP, SIBLING)); + } + + // --- 12: great niece/nephew (SIBLING, DOWN, DOWN) --- + @Test + void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() { + Person grandparent = person(); + Person sibling = person(); + Person greatUncle = person(); + Person niece = person(); + Person greatNiece = person(); + givenEdges( + parentOf(grandparent, sibling), + parentOf(grandparent, greatUncle), + parentOf(sibling, niece), + parentOf(niece, greatNiece)); + + assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId())) + .hasValue(List.of(SIBLING, DOWN, DOWN)); + } + + // --- 13: parent-in-law (SPOUSE, UP) --- + @Test + void inlaw_parent_path_emits_SPOUSE_UP() { + Person inlaw = person(); + Person spouse = person(); + Person me = person(); + givenEdges( + parentOf(inlaw, spouse), + spouseOf(me, spouse)); + + assertThat(service.findShortestPath(me.getId(), inlaw.getId())) + .hasValue(List.of(SPOUSE, UP)); + } + + // --- 14: child-in-law (DOWN, SPOUSE) --- + @Test + void inlaw_child_path_emits_DOWN_SPOUSE() { + Person me = person(); + Person child = person(); + Person inlawChild = person(); + givenEdges( + parentOf(me, child), + spouseOf(child, inlawChild)); + + assertThat(service.findShortestPath(me.getId(), inlawChild.getId())) + .hasValue(List.of(DOWN, SPOUSE)); + } + + // --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) --- + @Test + void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() { + Person me = person(); + Person spouse = person(); + Person spouseSibling = person(); + givenEdges( + spouseOf(me, spouse), + siblingOf(spouse, spouseSibling)); + + assertThat(service.findShortestPath(me.getId(), spouseSibling.getId())) + .hasValue(List.of(SPOUSE, SIBLING)); + } + + // --- 16: cousin (UP, SIBLING, DOWN) --- + @Test + void cousin_1_path_emits_UP_SIBLING_DOWN() { + Person ggp = person(); + Person parentMine = person(); + Person uncle = person(); + Person me = person(); + Person cousin = person(); + givenEdges( + parentOf(ggp, parentMine), + parentOf(ggp, uncle), + parentOf(parentMine, me), + parentOf(uncle, cousin)); + + assertThat(service.findShortestPath(me.getId(), cousin.getId())) + .hasValue(List.of(UP, SIBLING, DOWN)); + } + + // --- 17: distant (label fallback for long chains) --- + @Test + void distant_label_for_long_chain() { + // Seven-generation ancestor: chain of seven PARENT_OF edges. + Person a0 = person(); + Person a1 = person(); + Person a2 = person(); + Person a3 = person(); + Person a4 = person(); + Person a5 = person(); + Person a6 = person(); + Person a7 = person(); + givenEdges( + parentOf(a0, a1), + parentOf(a1, a2), + parentOf(a2, a3), + parentOf(a3, a4), + parentOf(a4, a5), + parentOf(a5, a6), + parentOf(a6, a7)); + + Optional inferred = service.infer(a7.getId(), a0.getId()); + assertThat(inferred).hasValueSatisfying(r -> { + assertThat(r.hops()).isEqualTo(7); + assertThat(r.labelFromA()).isEqualTo("distant"); + assertThat(r.labelFromB()).isEqualTo("distant"); + }); + } + + // --- 18: no path --- + @Test + void returns_empty_when_no_path() { + Person a = person(); + Person b = person(); + // No edges between them. + givenEdges(/* none */); + + assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty(); + assertThat(service.infer(a.getId(), b.getId())).isEmpty(); + } + + // --- helpers --- + + private void givenEdges(PersonRelationship... edges) { + when(relationshipRepository.findAllByRelationTypeIn(anyCollection())) + .thenReturn(edges.length == 0 ? emptyList() : List.of(edges)); + } + + private static Person person() { + return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build(); + } + + private static PersonRelationship parentOf(Person parent, Person child) { + return edge(parent, child, PARENT_OF); + } + + private static PersonRelationship spouseOf(Person a, Person b) { + return edge(a, b, SPOUSE_OF); + } + + private static PersonRelationship siblingOf(Person a, Person b) { + return edge(a, b, SIBLING_OF); + } + + private static PersonRelationship edge(Person a, Person b, RelationType type) { + return PersonRelationship.builder() + .id(UUID.randomUUID()) + .person(a) + .relatedPerson(b) + .relationType(type) + .createdAt(Instant.now()) + .build(); + } +} -- 2.49.1 From 790c6f5b028b4cf0f45cec3a1838ca40eb8ebf9b Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:20:15 +0200 Subject: [PATCH 04/57] feat(stammbaum): RelationshipService + family_member toggle (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PersonService.setFamilyMember (write, @Transactional) and findAllFamilyMembers; PersonRepository gains the findByFamilyMemberTrueOrderBy projection. - RelationshipService orchestrates PersonService + the inference service; never reaches into PersonRepository directly. addRelationship guards self-relationship, year range, circular PARENT_OF (Nora B2), and DataIntegrityViolation→DUPLICATE_RELATIONSHIP. deleteRelationship enforces ownership from either side (Nora B1). - Extend RelationshipDTO with personDisplayName + birth/death year so the frontend can render rows from either viewpoint. - 8 unit tests, written against a stub (red), then green: FORBIDDEN delete, CIRCULAR add, DUPLICATE add, self-relationship, year range, happy-path persistence, ownership-from-object, RELATIONSHIP_NOT_FOUND. Full backend suite: 1399/1399 green. Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- .../relationship/RelationshipService.java | 179 +++++++++++++++++ .../relationship/dto/RelationshipDTO.java | 14 +- .../repository/PersonRepository.java | 3 + .../familienarchiv/service/PersonService.java | 11 ++ .../relationship/RelationshipServiceTest.java | 185 ++++++++++++++++++ 5 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java new file mode 100644 index 00000000..03ed5d54 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java @@ -0,0 +1,179 @@ +package org.raddatz.familienarchiv.relationship; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO; +import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO; +import org.raddatz.familienarchiv.relationship.dto.NetworkDTO; +import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO; +import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO; +import org.raddatz.familienarchiv.service.PersonService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * Owns the {@code person_relationships} table and the family_member flag. + * Always orchestrates {@link PersonService} for cross-domain access — never + * touches {@link org.raddatz.familienarchiv.repository.PersonRepository}. + */ +@Service +@RequiredArgsConstructor +public class RelationshipService { + + private final PersonRelationshipRepository relationshipRepository; + private final PersonService personService; + private final RelationshipInferenceService inferenceService; + + public List getRelationships(UUID personId) { + personService.getById(personId); + List rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId); + return rels.stream().map(RelationshipService::toDTO).toList(); + } + + public List getInferredRelationships(UUID personId) { + personService.getById(personId); + return inferenceService.findAllFor(personId); + } + + public Optional getRelationshipBetween(UUID a, UUID b) { + personService.getById(a); + personService.getById(b); + return inferenceService.infer(a, b); + } + + public NetworkDTO getFamilyNetwork() { + // Two queries: 1 for nodes (family members), 1 for edges (family-graph types). + List familyMembers = personService.findAllFamilyMembers(); + Set familyIds = new HashSet<>(familyMembers.size()); + List nodes = new ArrayList<>(familyMembers.size()); + for (Person p : familyMembers) { + familyIds.add(p.getId()); + nodes.add(new PersonNodeDTO( + p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true)); + } + + List familyEdges = relationshipRepository.findAllByRelationTypeIn( + List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF)); + + List edges = new ArrayList<>(); + for (PersonRelationship r : familyEdges) { + UUID p = r.getPerson().getId(); + UUID rp = r.getRelatedPerson().getId(); + if (familyIds.contains(p) && familyIds.contains(rp)) { + edges.add(toDTO(r)); + } + } + return new NetworkDTO(nodes, edges); + } + + @Transactional + public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) { + if (personId.equals(dto.relatedPersonId())) { + throw DomainException.badRequest( + ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves"); + } + Person person = personService.getById(personId); + Person relatedPerson = personService.getById(dto.relatedPersonId()); + + RelationType type = parseType(dto.relationType()); + validateYears(dto.fromYear(), dto.toYear()); + + if (type == RelationType.PARENT_OF + && relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( + relatedPerson.getId(), personId, RelationType.PARENT_OF)) { + throw DomainException.conflict( + ErrorCode.CIRCULAR_RELATIONSHIP, + "Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId()); + } + + PersonRelationship rel = PersonRelationship.builder() + .person(person) + .relatedPerson(relatedPerson) + .relationType(type) + .fromYear(dto.fromYear()) + .toYear(dto.toYear()) + .notes(blankToNull(dto.notes())) + .build(); + + try { + return toDTO(relationshipRepository.save(rel)); + } catch (DataIntegrityViolationException e) { + throw DomainException.conflict( + ErrorCode.DUPLICATE_RELATIONSHIP, + "Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + type + ")"); + } + } + + @Transactional + public void deleteRelationship(UUID personId, UUID relId) { + PersonRelationship rel = relationshipRepository.findById(relId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId)); + + UUID storageSubject = rel.getPerson().getId(); + UUID storageObject = rel.getRelatedPerson().getId(); + if (!personId.equals(storageSubject) && !personId.equals(storageObject)) { + throw DomainException.forbidden( + "Relationship " + relId + " does not belong to person " + personId); + } + relationshipRepository.delete(rel); + } + + @Transactional + public Person setFamilyMember(UUID personId, boolean familyMember) { + return personService.setFamilyMember(personId, familyMember); + } + + private static String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } + + private static RelationType parseType(String typeName) { + if (typeName == null) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "relationType is required"); + } + try { + return RelationType.valueOf(typeName); + } catch (IllegalArgumentException e) { + throw DomainException.badRequest( + ErrorCode.VALIDATION_ERROR, "Invalid relationType: " + typeName); + } + } + + private static void validateYears(Integer fromYear, Integer toYear) { + if (fromYear != null && toYear != null && toYear < fromYear) { + throw DomainException.badRequest( + ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear"); + } + } + + private static RelationshipDTO toDTO(PersonRelationship r) { + Person p = r.getPerson(); + Person rp = r.getRelatedPerson(); + return new RelationshipDTO( + r.getId(), + p.getId(), + rp.getId(), + p.getDisplayName(), + p.getBirthYear(), + p.getDeathYear(), + rp.getDisplayName(), + rp.getBirthYear(), + rp.getDeathYear(), + r.getRelationType(), + r.getFromYear(), + r.getToYear(), + r.getNotes()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java index 82792ea0..3ecfe018 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java @@ -6,14 +6,22 @@ import org.raddatz.familienarchiv.relationship.RelationType; import java.util.UUID; /** - * Wire shape for a stored relationship row. Carries enough context for the - * frontend to render a chip (type), a name (relatedPersonDisplayName), a year - * range, and a delete action (id). + * Wire shape for one stored relationship row. Both sides include name + years + * so the frontend can render the row from either perspective (e.g. on the + * subject's page the row reads "Elternteil von [related]"; on the object's + * page it reads "Kind von [person]"). + * + *

Storage truth: {@code personId} is the {@code person_id} column, + * {@code relatedPersonId} is the {@code related_person_id} column. The + * frontend determines orientation by comparing against the viewpoint. */ public record RelationshipDTO( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String personDisplayName, + Integer personBirthYear, + Integer personDeathYear, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName, Integer relatedPersonBirthYear, Integer relatedPersonDeathYear, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index 6138510f..3a5eb3af 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -26,6 +26,9 @@ public interface PersonRepository extends JpaRepository { // Hilfsmethode: Alle sortiert laden (für den leeren Status) List findAllByOrderByLastNameAscFirstNameAsc(); + // Stammbaum-Knoten: alle Personen mit family_member = true. + List findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc(); + // Lookup by full alias string, used during ODS mass import Optional findByAliasIgnoreCase(String alias); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index 93ccdd5e..df04aa38 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -58,6 +58,17 @@ public class PersonService { return personRepository.findAllById(ids); } + public List findAllFamilyMembers() { + return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc(); + } + + @Transactional + public Person setFamilyMember(UUID personId, boolean familyMember) { + Person person = getById(personId); + person.setFamilyMember(familyMember); + return personRepository.save(person); + } + public Optional findByName(String firstName, String lastName) { return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java new file mode 100644 index 00000000..4383796a --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java @@ -0,0 +1,185 @@ +package org.raddatz.familienarchiv.relationship; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.service.PersonService; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Felix Brandt — TDD red for RelationshipService domain rules. + * + *

Required by the plan (Nora blockers 1 + 2): + *

    + *
  • {@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}
  • + *
  • {@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}
  • + *
+ * Plus: duplicate constraint, self-relationship, year-range, happy-path persistence, + * and ownership permitted from either side. + */ +@ExtendWith(MockitoExtension.class) +class RelationshipServiceTest { + + @Mock PersonRelationshipRepository relationshipRepository; + @Mock PersonService personService; + @Mock RelationshipInferenceService inferenceService; + @InjectMocks RelationshipService service; + + Person alice; + Person bob; + Person charlie; + + @BeforeEach + void seed() { + alice = person("Alice"); + bob = person("Bob"); + charlie = person("Charlie"); + } + + // --- Nora blocker 1 --- + @Test + void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = parentOf(alice, bob, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + + assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.FORBIDDEN); + verify(relationshipRepository, never()).delete(any()); + } + + // --- Nora blocker 2 --- + @Test + void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() { + // alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice. + when(personService.getById(bob.getId())).thenReturn(bob); + when(personService.getById(alice.getId())).thenReturn(alice); + when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( + alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true); + + var dto = new CreateRelationshipRequest(alice.getId(), "PARENT_OF", null, null, null); + assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP); + verify(relationshipRepository, never()).save(any()); + } + + @Test + void addRelationship_throws_DUPLICATE_when_db_constraint_violated() { + when(personService.getById(alice.getId())).thenReturn(alice); + when(personService.getById(bob.getId())).thenReturn(bob); + when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( + bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false); + when(relationshipRepository.save(any())).thenThrow(new DataIntegrityViolationException("unique_rel")); + + var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null); + assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP); + } + + @Test + void addRelationship_throws_BAD_REQUEST_when_self_relationship() { + var dto = new CreateRelationshipRequest(alice.getId(), "FRIEND", null, null, null); + assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + verify(relationshipRepository, never()).save(any()); + } + + @Test + void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() { + when(personService.getById(alice.getId())).thenReturn(alice); + when(personService.getById(bob.getId())).thenReturn(bob); + var dto = new CreateRelationshipRequest(bob.getId(), "FRIEND", 1950, 1940, null); + assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + verify(relationshipRepository, never()).save(any()); + } + + @Test + void addRelationship_persists_with_storage_truth() { + when(personService.getById(alice.getId())).thenReturn(alice); + when(personService.getById(bob.getId())).thenReturn(bob); + when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( + bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false); + when(relationshipRepository.save(any())).thenAnswer(inv -> { + PersonRelationship r = inv.getArgument(0); + r.setId(UUID.randomUUID()); + r.setCreatedAt(Instant.now()); + return r; + }); + + var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", 1900, null, "first born"); + var result = service.addRelationship(alice.getId(), dto); + + assertThat(result.personId()).isEqualTo(alice.getId()); + assertThat(result.relatedPersonId()).isEqualTo(bob.getId()); + assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF); + assertThat(result.fromYear()).isEqualTo(1900); + assertThat(result.notes()).isEqualTo("first born"); + } + + @Test + void deleteRelationship_succeeds_when_viewpoint_is_object() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = parentOf(alice, bob, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + + // Bob is the storage related_person; deleting from his viewpoint should work. + service.deleteRelationship(bob.getId(), relId); + verify(relationshipRepository).delete(rel); + } + + @Test + void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() { + UUID relId = UUID.randomUUID(); + when(relationshipRepository.findById(relId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); + } + + // --- helpers --- + + private static Person person(String name) { + return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build(); + } + + private static PersonRelationship parentOf(Person parent, Person child, UUID id) { + return PersonRelationship.builder() + .id(id) + .person(parent) + .relatedPerson(child) + .relationType(RelationType.PARENT_OF) + .createdAt(Instant.now()) + .build(); + } +} -- 2.49.1 From f29f4d3f5b834b3f6a5c1cc41638055d415eb7f6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:22:34 +0200 Subject: [PATCH 05/57] feat(stammbaum): RelationshipController for the Stammbaum API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven endpoints in one controller, two roots: - GET /api/network → NetworkDTO - GET /api/persons/{id}/relationships → List - GET /api/persons/{id}/inferred-relationships - GET /api/persons/{aId}/relationship-to/{bId} → 200 or 404 - POST /api/persons/{id}/relationships WRITE_ALL - DEL /api/persons/{id}/relationships/{relId} WRITE_ALL, 204 - PATCH /api/persons/{id}/family-member WRITE_ALL PersonController is intentionally untouched. Controller-boundary validation via RelationType.valueOf catches unknown types as 400 before the service is invoked. FamilyMemberPatchDTO is a one-field record for the family-member toggle. Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- .../relationship/RelationshipController.java | 92 +++++++++++++++++++ .../dto/FamilyMemberPatchDTO.java | 4 + 2 files changed, 96 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/FamilyMemberPatchDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipController.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipController.java new file mode 100644 index 00000000..1a94cc29 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipController.java @@ -0,0 +1,92 @@ +package org.raddatz.familienarchiv.relationship; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO; +import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO; +import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO; +import org.raddatz.familienarchiv.relationship.dto.NetworkDTO; +import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.UUID; + +/** + * Stammbaum API. Endpoints split across two roots: + *
    + *
  • {@code /api/network} — the family graph
  • + *
  • {@code /api/persons/{id}/...} — per-person relationship operations + * (PersonController is intentionally left untouched)
  • + *
+ */ +@RestController +@RequiredArgsConstructor +public class RelationshipController { + + private final RelationshipService relationshipService; + + @GetMapping("/api/network") + public NetworkDTO getNetwork() { + return relationshipService.getFamilyNetwork(); + } + + @GetMapping("/api/persons/{id}/relationships") + public List getRelationships(@PathVariable UUID id) { + return relationshipService.getRelationships(id); + } + + @GetMapping("/api/persons/{id}/inferred-relationships") + public List getInferredRelationships(@PathVariable UUID id) { + return relationshipService.getInferredRelationships(id); + } + + @GetMapping("/api/persons/{aId}/relationship-to/{bId}") + public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) { + return relationshipService.getRelationshipBetween(aId, bId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "No relationship path between " + aId + " and " + bId)); + } + + @PostMapping("/api/persons/{id}/relationships") + @RequirePermission(Permission.WRITE_ALL) + public ResponseEntity addRelationship( + @PathVariable UUID id, + @Valid @RequestBody CreateRelationshipRequest dto) { + validateRelationType(dto.relationType()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(relationshipService.addRelationship(id, dto)); + } + + @DeleteMapping("/api/persons/{id}/relationships/{relId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission(Permission.WRITE_ALL) + public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) { + relationshipService.deleteRelationship(id, relId); + } + + @PatchMapping("/api/persons/{id}/family-member") + @RequirePermission(Permission.WRITE_ALL) + public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) { + return relationshipService.setFamilyMember(id, dto.familyMember()); + } + + private static void validateRelationType(String typeName) { + if (typeName == null || typeName.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "relationType is required"); + } + try { + RelationType.valueOf(typeName); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unknown relationType: " + typeName); + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/FamilyMemberPatchDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/FamilyMemberPatchDTO.java new file mode 100644 index 00000000..74d0bcb5 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/FamilyMemberPatchDTO.java @@ -0,0 +1,4 @@ +package org.raddatz.familienarchiv.relationship.dto; + +/** Body for {@code PATCH /api/persons/{id}/family-member}. */ +public record FamilyMemberPatchDTO(boolean familyMember) {} -- 2.49.1 From 050f2bc9296155f1279a463b4813a50180e8ea85 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:28:48 +0200 Subject: [PATCH 06/57] test(stammbaum): integration tests for relationship constraints @DataJpaTest + Postgres Testcontainer; 7 cases per Sara blocker 1: - addRelationship_stores_and_is_readable - addRelationship_throws_409_when_duplicate (unique_rel) - addRelationship_throws_409_when_circular_parent - deleteRelationship_throws_403_when_rel_belongs_to_different_person - deleteRelationship_succeeds_for_symmetric_type_from_either_side - setFamilyMember_true_makes_person_appear_in_network - delete_person_cascades_to_relationships Service now uses saveAndFlush so the unique_rel violation surfaces synchronously inside the @Transactional method (otherwise the DataIntegrityViolation fires at commit time, outside the try-catch). Unit-test mocks updated accordingly. Backend suite: 1406/1406 green. Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- .../relationship/RelationshipService.java | 4 +- .../RelationshipServiceIntegrationTest.java | 167 ++++++++++++++++++ .../relationship/RelationshipServiceTest.java | 10 +- 3 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceIntegrationTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java index 03ed5d54..14f46018 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java @@ -107,7 +107,9 @@ public class RelationshipService { .build(); try { - return toDTO(relationshipRepository.save(rel)); + // saveAndFlush so the unique_rel constraint violates synchronously and is + // caught here, not at commit time outside the @Transactional boundary. + return toDTO(relationshipRepository.saveAndFlush(rel)); } catch (DataIntegrityViolationException e) { throw DomainException.conflict( ErrorCode.DUPLICATE_RELATIONSHIP, diff --git a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceIntegrationTest.java new file mode 100644 index 00000000..8654fa1d --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceIntegrationTest.java @@ -0,0 +1,167 @@ +package org.raddatz.familienarchiv.relationship; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.relationship.dto.NetworkDTO; +import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO; +import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; +import org.raddatz.familienarchiv.repository.PersonRepository; +import org.raddatz.familienarchiv.service.PersonService; +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 jakarta.persistence.EntityManager; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Sara blocker 1 — service+DB integration over the family-network constraints. + * Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial + * sibling index actually fire. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({ + PostgresContainerConfig.class, + FlywayConfig.class, + RelationshipService.class, + RelationshipInferenceService.class, + PersonService.class +}) +class RelationshipServiceIntegrationTest { + + @Autowired RelationshipService relationshipService; + @Autowired PersonRepository personRepository; + @Autowired PersonRelationshipRepository relationshipRepository; + // PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it. + @Autowired PersonNameAliasRepository aliasRepository; + @Autowired EntityManager entityManager; + + Person alice; + Person bob; + Person charlie; + + @BeforeEach + void seed() { + relationshipRepository.deleteAll(); + aliasRepository.deleteAll(); + personRepository.deleteAll(); + alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build()); + bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build()); + charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build()); + } + + @Test + void addRelationship_stores_and_is_readable() { + var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", 1900, null, null); + + RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto); + + assertThat(created.id()).isNotNull(); + assertThat(created.personId()).isEqualTo(alice.getId()); + assertThat(created.relatedPersonId()).isEqualTo(bob.getId()); + + List rels = relationshipService.getRelationships(alice.getId()); + assertThat(rels).hasSize(1); + assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF); + } + + @Test + void addRelationship_throws_409_when_duplicate() { + var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null); + relationshipService.addRelationship(alice.getId(), dto); + + assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP); + } + + @Test + void addRelationship_throws_409_when_circular_parent() { + // alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected. + relationshipService.addRelationship(alice.getId(), + new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null)); + + var reverse = new CreateRelationshipRequest(alice.getId(), "PARENT_OF", null, null, null); + assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP); + } + + @Test + void deleteRelationship_throws_403_when_rel_belongs_to_different_person() { + RelationshipDTO created = relationshipService.addRelationship(alice.getId(), + new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null)); + + // Charlie is unrelated to this row. + assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id())) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.FORBIDDEN); + + // The row is still there. + assertThat(relationshipRepository.findById(created.id())).isPresent(); + } + + @Test + void deleteRelationship_succeeds_for_symmetric_type_from_either_side() { + // alice SPOUSE_OF bob. Bob deletes from his side. + RelationshipDTO created = relationshipService.addRelationship(alice.getId(), + new CreateRelationshipRequest(bob.getId(), "SPOUSE_OF", null, null, null)); + + relationshipService.deleteRelationship(bob.getId(), created.id()); + + assertThat(relationshipRepository.findById(created.id())).isEmpty(); + } + + @Test + void setFamilyMember_true_makes_person_appear_in_network() { + // charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie + // so the edge exists, then flip charlie's flag and verify he appears in nodes. + relationshipService.addRelationship(alice.getId(), + new CreateRelationshipRequest(charlie.getId(), "PARENT_OF", null, null, null)); + + NetworkDTO before = relationshipService.getFamilyNetwork(); + assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId()); + + relationshipService.setFamilyMember(charlie.getId(), true); + + NetworkDTO after = relationshipService.getFamilyNetwork(); + assertThat(after.nodes()).extracting("id").contains(charlie.getId()); + assertThat(after.edges()) + .anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId())); + } + + @Test + void delete_person_cascades_to_relationships() { + RelationshipDTO created = relationshipService.addRelationship(alice.getId(), + new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null)); + UUID relId = created.id(); + assertThat(relationshipRepository.findById(relId)).isPresent(); + + // Detach managed entities so deleteById's cascade isn't fought by the + // persistence context (the rel row still references bob in memory). + entityManager.flush(); + entityManager.clear(); + + // Delete bob (the relatedPerson) — DB CASCADE must remove the row. + personRepository.deleteById(bob.getId()); + personRepository.flush(); + + assertThat(relationshipRepository.findById(relId)).isEmpty(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java index 4383796a..be915e3b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java @@ -82,7 +82,7 @@ class RelationshipServiceTest { .isInstanceOf(DomainException.class) .extracting("code") .isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP); - verify(relationshipRepository, never()).save(any()); + verify(relationshipRepository, never()).saveAndFlush(any()); } @Test @@ -91,7 +91,7 @@ class RelationshipServiceTest { when(personService.getById(bob.getId())).thenReturn(bob); when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false); - when(relationshipRepository.save(any())).thenThrow(new DataIntegrityViolationException("unique_rel")); + when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel")); var dto = new CreateRelationshipRequest(bob.getId(), "PARENT_OF", null, null, null); assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) @@ -107,7 +107,7 @@ class RelationshipServiceTest { .isInstanceOf(DomainException.class) .extracting("code") .isEqualTo(ErrorCode.VALIDATION_ERROR); - verify(relationshipRepository, never()).save(any()); + verify(relationshipRepository, never()).saveAndFlush(any()); } @Test @@ -119,7 +119,7 @@ class RelationshipServiceTest { .isInstanceOf(DomainException.class) .extracting("code") .isEqualTo(ErrorCode.VALIDATION_ERROR); - verify(relationshipRepository, never()).save(any()); + verify(relationshipRepository, never()).saveAndFlush(any()); } @Test @@ -128,7 +128,7 @@ class RelationshipServiceTest { when(personService.getById(bob.getId())).thenReturn(bob); when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false); - when(relationshipRepository.save(any())).thenAnswer(inv -> { + when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> { PersonRelationship r = inv.getArgument(0); r.setId(UUID.randomUUID()); r.setCreatedAt(Instant.now()); -- 2.49.1 From fc46704144950dd307dd5cfd0167eb202441e99e Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:36:11 +0200 Subject: [PATCH 07/57] chore(stammbaum): regenerate TS API types for relationship endpoints openapi-typescript pulled the Stammbaum schemas: Person now has familyMember (required), plus PersonNodeDTO, NetworkDTO, RelationshipDTO, InferredRelationshipDTO, InferredRelationshipWithPersonDTO, CreateRelationshipRequest, FamilyMemberPatchDTO. Routes: /api/network, /api/persons/{id}/relationships, /api/persons/{id}/inferred-relationships, /api/persons/{aId}/relationship-to/{bId}, and the family-member PATCH. Test fixtures in PersonMultiSelect, briefwechsel page, and DocumentList specs gained familyMember: false where they otherwise typed Person end-to-end. Pre-existing "missing lastName/personType" fixture errors in DocumentRow.spec are out of scope. Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- .../PersonMultiSelect.svelte.spec.ts | 48 ++- frontend/src/lib/generated/api.ts | 338 +++++++++++++++++- .../src/routes/DocumentList.svelte.spec.ts | 33 +- .../routes/briefwechsel/page.svelte.spec.ts | 1 + 4 files changed, 394 insertions(+), 26 deletions(-) diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts b/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts index 08c2ba28..9166c1a4 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts @@ -12,16 +12,25 @@ const PERSONS = [ firstName: 'Max', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false }, { id: '2', firstName: 'Anna', lastName: 'Musterfrau', displayName: 'Anna Musterfrau', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false }, - { id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' } + { + id: '3', + firstName: 'Karl', + lastName: 'König', + displayName: 'Karl König', + personType: 'PERSON', + familyMember: false + } ]; function mockFetch(persons = PERSONS) { @@ -62,14 +71,16 @@ describe('PersonMultiSelect – rendering', () => { firstName: 'Max', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false }, { id: '2', firstName: 'Anna', lastName: 'Musterfrau', displayName: 'Anna Musterfrau', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } ] }); @@ -86,14 +97,16 @@ describe('PersonMultiSelect – rendering', () => { firstName: 'Max', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false }, { id: '2', firstName: 'Anna', lastName: 'Musterfrau', displayName: 'Anna Musterfrau', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } ] }); @@ -112,7 +125,8 @@ describe('PersonMultiSelect – rendering', () => { firstName: 'Max', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } ] }); @@ -166,7 +180,8 @@ describe('PersonMultiSelect – selecting persons', () => { firstName: 'Max', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } ] }); @@ -187,7 +202,8 @@ describe('PersonMultiSelect – selecting persons', () => { firstName: 'Max', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } ]); render(PersonMultiSelect, { selectedPersons: [] }); @@ -210,14 +226,16 @@ describe('PersonMultiSelect – removing persons', () => { firstName: 'Max', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false }, { id: '2', firstName: 'Anna', lastName: 'Musterfrau', displayName: 'Anna Musterfrau', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } ] }); @@ -236,14 +254,16 @@ describe('PersonMultiSelect – removing persons', () => { firstName: 'Max', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false }, { id: '2', firstName: 'Anna', lastName: 'Musterfrau', displayName: 'Anna Musterfrau', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } ] }); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 81925006..30cec4fb 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -212,6 +212,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/relationships": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRelationships"]; + put?: never; + post: operations["addRelationship"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/persons/{id}/merge": { parameters: { query?: never; @@ -612,6 +628,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/family-member": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["patchFamilyMember"]; + trace?: never; + }; "/api/notifications/{id}/read": { parameters: { query?: never; @@ -852,6 +884,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/inferred-relationships": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getInferredRelationships"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/persons/{id}/documents": { parameters: { query?: never; @@ -884,6 +932,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{aId}/relationship-to/{bId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRelationshipBetween"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/ocr/training-info": { parameters: { query?: never; @@ -1044,6 +1108,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/network": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getNetwork"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{id}/versions": { parameters: { query?: never; @@ -1332,6 +1412,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/relationships/{relId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteRelationship"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/persons/{id}/aliases/{aliasId}": { parameters: { query?: never; @@ -1430,6 +1526,8 @@ export interface components { color?: string; }; PersonUpdateDTO: { + /** @enum {string} */ + personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP"; title?: string; firstName?: string; lastName?: string; @@ -1454,6 +1552,7 @@ export interface components { birthYear?: number; /** Format: int32 */ deathYear?: number; + familyMember: boolean; readonly displayName: string; }; DocumentUpdateDTO: { @@ -1462,6 +1561,8 @@ export interface components { documentDate?: string; location?: string; documentLocation?: string; + archiveBox?: string; + archiveFolder?: string; transcription?: string; summary?: string; /** Format: uuid */ @@ -1561,6 +1662,41 @@ export interface components { /** Format: uuid */ targetId: string; }; + CreateRelationshipRequest: { + /** Format: uuid */ + relatedPersonId: string; + relationType: string; + /** Format: int32 */ + fromYear?: number; + /** Format: int32 */ + toYear?: number; + notes?: string; + }; + RelationshipDTO: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + personId: string; + /** Format: uuid */ + relatedPersonId: string; + personDisplayName: string; + /** Format: int32 */ + personBirthYear?: number; + /** Format: int32 */ + personDeathYear?: number; + relatedPersonDisplayName: string; + /** Format: int32 */ + relatedPersonBirthYear?: number; + /** Format: int32 */ + relatedPersonDeathYear?: number; + /** @enum {string} */ + relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; + /** Format: int32 */ + fromYear?: number; + /** Format: int32 */ + toYear?: number; + notes?: string; + }; PersonNameAliasDTO: { lastName: string; firstName?: string; @@ -1808,6 +1944,9 @@ export interface components { /** Format: int32 */ count: number; }; + FamilyMemberPatchDTO: { + familyMember?: boolean; + }; NotificationDTO: { /** Format: uuid */ id: string; @@ -1912,15 +2051,38 @@ export interface components { displayName?: string; firstName?: string; lastName?: string; - /** Format: int64 */ - documentCount?: number; + personType?: string; /** Format: int32 */ birthYear?: number; /** Format: int32 */ deathYear?: number; - alias?: string; + familyMember?: boolean; notes?: string; - personType?: string; + /** Format: int64 */ + documentCount?: number; + alias?: string; + }; + InferredRelationshipWithPersonDTO: { + person: components["schemas"]["PersonNodeDTO"]; + label: string; + /** Format: int32 */ + hops: number; + }; + PersonNodeDTO: { + /** Format: uuid */ + id: string; + displayName: string; + /** Format: int32 */ + birthYear?: number; + /** Format: int32 */ + deathYear?: number; + familyMember: boolean; + }; + InferredRelationshipDTO: { + labelFromA: string; + labelFromB: string; + /** Format: int32 */ + hops: number; }; SenderModel: { /** Format: uuid */ @@ -2021,6 +2183,10 @@ export interface components { empty?: boolean; unsorted?: boolean; }; + NetworkDTO: { + nodes: components["schemas"]["PersonNodeDTO"][]; + edges: components["schemas"]["RelationshipDTO"][]; + }; DocumentVersionSummary: { /** Format: uuid */ id: string; @@ -2131,7 +2297,7 @@ export interface components { }; ActivityFeedItemDTO: { /** @enum {string} */ - kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED"; + kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED"; actor?: components["schemas"]["ActivityActorDTO"]; /** Format: uuid */ documentId: string; @@ -2745,6 +2911,54 @@ export interface operations { }; }; }; + getRelationships: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["RelationshipDTO"][]; + }; + }; + }; + }; + addRelationship: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRelationshipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["RelationshipDTO"]; + }; + }; + }; + }; mergePerson: { parameters: { query?: never; @@ -3491,6 +3705,32 @@ export interface operations { }; }; }; + patchFamilyMember: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FamilyMemberPatchDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Person"]; + }; + }; + }; + }; markOneRead: { parameters: { query?: never; @@ -3889,6 +4129,28 @@ export interface operations { }; }; }; + getInferredRelationships: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["InferredRelationshipWithPersonDTO"][]; + }; + }; + }; + }; getPersonDocuments: { parameters: { query?: never; @@ -3935,6 +4197,29 @@ export interface operations { }; }; }; + getRelationshipBetween: { + parameters: { + query?: never; + header?: never; + path: { + aId: string; + bId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["InferredRelationshipDTO"]; + }; + }; + }; + }; getTrainingInfo: { parameters: { query?: never; @@ -4150,6 +4435,26 @@ export interface operations { }; }; }; + getNetwork: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["NetworkDTO"]; + }; + }; + }; + }; getVersions: { parameters: { query?: never; @@ -4470,7 +4775,7 @@ export interface operations { query?: { limit?: number; /** @description Filter by audit kinds; omit for all rollup-eligible kinds */ - kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED")[]; + kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[]; }; header?: never; path?: never; @@ -4571,6 +4876,27 @@ export interface operations { }; }; }; + deleteRelationship: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + relId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; removeAlias: { parameters: { query?: never; diff --git a/frontend/src/routes/DocumentList.svelte.spec.ts b/frontend/src/routes/DocumentList.svelte.spec.ts index a41c92b9..19a11676 100644 --- a/frontend/src/routes/DocumentList.svelte.spec.ts +++ b/frontend/src/routes/DocumentList.svelte.spec.ts @@ -131,7 +131,8 @@ describe('DocumentList – sender grouping', () => { id: 's1', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } } }), @@ -143,7 +144,8 @@ describe('DocumentList – sender grouping', () => { id: 's2', lastName: 'Musterfrau', displayName: 'Anna Musterfrau', - personType: 'PERSON' + personType: 'PERSON', + familyMember: false } } }) @@ -162,7 +164,8 @@ describe('DocumentList – sender grouping', () => { id: 's1', lastName: 'Mustermann', displayName: 'Max Mustermann', - personType: 'PERSON' as const + personType: 'PERSON' as const, + familyMember: false }; const items = [ makeItem({ document: { ...makeItem().document, id: '1', sender } }), @@ -191,7 +194,13 @@ describe('DocumentList – receiver grouping', () => { ...makeItem().document, id: '1', receivers: [ - { id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' } + { + id: 'r1', + lastName: 'Brandt', + displayName: 'Felix Brandt', + personType: 'PERSON', + familyMember: false + } ] } }) @@ -210,8 +219,20 @@ describe('DocumentList – receiver grouping', () => { id: '1', title: 'Rundbriefchen', receivers: [ - { id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' }, - { id: 'r2', lastName: 'Meier', displayName: 'Hans Meier', personType: 'PERSON' } + { + id: 'r1', + lastName: 'Brandt', + displayName: 'Felix Brandt', + personType: 'PERSON', + familyMember: false + }, + { + id: 'r2', + lastName: 'Meier', + displayName: 'Hans Meier', + personType: 'PERSON', + familyMember: false + } ] } }) diff --git a/frontend/src/routes/briefwechsel/page.svelte.spec.ts b/frontend/src/routes/briefwechsel/page.svelte.spec.ts index d861f196..9b4e720f 100644 --- a/frontend/src/routes/briefwechsel/page.svelte.spec.ts +++ b/frontend/src/routes/briefwechsel/page.svelte.spec.ts @@ -35,6 +35,7 @@ const makePerson = (overrides: Record = {}) => ({ firstName: 'Hans', lastName: 'Müller', personType: 'PERSON' as const, + familyMember: false, displayName: 'Hans Müller', ...overrides }); -- 2.49.1 From 51db9763482fced089ab11e3a15103cf405a1a8d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:40:25 +0200 Subject: [PATCH 08/57] feat(stammbaum): add i18n keys (de/en/es) + mirror error codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In each of de/en/es: - nav_stammbaum - 9 relation__of keys for the stored relation types - 17 relation_inferred_