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 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ public interface PersonSummaryDTO {
|
|||||||
Integer getBirthYear();
|
Integer getBirthYear();
|
||||||
Integer getDeathYear();
|
Integer getDeathYear();
|
||||||
String getNotes();
|
String getNotes();
|
||||||
|
boolean isFamilyMember();
|
||||||
long getDocumentCount();
|
long getDocumentCount();
|
||||||
|
|
||||||
default String getDisplayName() {
|
default String getDisplayName() {
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ public enum ErrorCode {
|
|||||||
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
||||||
OCR_TRAINING_CONFLICT,
|
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 ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
TAG_NOT_FOUND,
|
TAG_NOT_FOUND,
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ public class Person {
|
|||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
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).
|
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
||||||
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
||||||
// separate DB roundtrip while respecting domain boundaries.
|
// separate DB roundtrip while respecting domain boundaries.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<PersonRelationship, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk fetch for the network endpoint — pulls only edges of the given types.
|
||||||
|
* The service filters by family_member afterwards.
|
||||||
|
*/
|
||||||
|
List<PersonRelationship> findAllByRelationTypeIn(Collection<RelationType> 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<PersonRelationship> findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn(
|
||||||
|
@Param("personId") UUID personId,
|
||||||
|
@Param("types") Collection<RelationType> 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<PersonRelationship> findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Family-network relationship taxonomy.
|
||||||
|
*
|
||||||
|
* <p>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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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<PersonNodeDTO> nodes,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<RelationshipDTO> edges
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -38,6 +38,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
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 documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -50,6 +51,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
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 documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -58,7 +60,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(a.last_name) 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
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
|
|||||||
Reference in New Issue
Block a user