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)