diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java index dff7d648..3fbaa1d8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import jakarta.validation.ConstraintViolationException; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -47,6 +48,12 @@ public class GlobalExceptionHandler { return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message)); } + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleMessageNotReadable(HttpMessageNotReadableException ex) { + return ResponseEntity.badRequest() + .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body")); + } + @ExceptionHandler(ResponseStatusException.class) public ResponseEntity handleResponseStatus(ResponseStatusException ex) { return ResponseEntity.status(ex.getStatusCode()) 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..4f204505 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/PersonRelationshipRepository.java @@ -0,0 +1,49 @@ +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. + */ + @Query("SELECT r FROM PersonRelationship r " + + "JOIN FETCH r.person " + + "JOIN FETCH r.relatedPerson " + + "WHERE r.relationType IN :types") + List findAllByRelationTypeIn(@Param("types") 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 " + + "JOIN FETCH r.person " + + "JOIN FETCH r.relatedPerson " + + "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/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/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/RelationshipController.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipController.java new file mode 100644 index 00000000..80f2faaa --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipController.java @@ -0,0 +1,84 @@ +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.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +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 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; + + // READ endpoints carry no @RequirePermission: all authenticated users may read the family graph. + // Unauthenticated requests are rejected by Spring Security's anyRequest().authenticated() rule. + + @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(() -> DomainException.notFound( + ErrorCode.RELATIONSHIP_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) { + 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()); + } + +} 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..7191c222 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java @@ -0,0 +1,211 @@ +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.service.PersonService; +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 { + + // 8 hops covers great-grandparents ↔ great-great-grandchildren and second cousins — + // the practical horizon for a 1899–1950 family archive. Paths longer than this are + // classified as LABEL_DISTANT and rarely carry meaningful relationship labels. + 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 PersonService personService; + + 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 : personService.getAllById(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/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..14f46018 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipService.java @@ -0,0 +1,181 @@ +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 { + // 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, + "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/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/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) {} 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..3ecfe018 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/dto/RelationshipDTO.java @@ -0,0 +1,32 @@ +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 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, + @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..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); @@ -38,6 +41,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 +54,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 +63,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) 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/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'; diff --git a/backend/src/main/resources/db/migration/V55__add_spouse_symmetric_index.sql b/backend/src/main/resources/db/migration/V55__add_spouse_symmetric_index.sql new file mode 100644 index 00000000..cee18767 --- /dev/null +++ b/backend/src/main/resources/db/migration/V55__add_spouse_symmetric_index.sql @@ -0,0 +1,6 @@ +-- Symmetric SPOUSE_OF: enforce only one row per unordered pair, mirroring the +-- SIBLING_OF index added in V54. +CREATE UNIQUE INDEX unique_spouse_pair ON person_relationships ( + LEAST(person_id, related_person_id), + GREATEST(person_id, related_person_id) +) WHERE relation_type = 'SPOUSE_OF'; 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/RelationshipControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipControllerTest.java new file mode 100644 index 00000000..b1b35f5e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipControllerTest.java @@ -0,0 +1,151 @@ +package org.raddatz.familienarchiv.relationship; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.exception.ErrorCode; +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.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(RelationshipController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class RelationshipControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean RelationshipService relationshipService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final UUID PERSON_ID = UUID.randomUUID(); + private static final UUID OTHER_ID = UUID.randomUUID(); + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getRelationshipBetween_returns404_with_RELATIONSHIP_NOT_FOUND_code_when_no_path() throws Exception { + when(relationshipService.getRelationshipBetween(any(), any())).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/persons/{aId}/relationship-to/{bId}", PERSON_ID, OTHER_ID)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.RELATIONSHIP_NOT_FOUND.name())); + } + + @Test + void getRelationships_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/persons/{id}/relationships", PERSON_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void getNetwork_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/network")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception { + mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception { + mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception { + mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"familyMember\":true}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception { + PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true); + RelationshipDTO edge = new RelationshipDTO( + UUID.randomUUID(), PERSON_ID, OTHER_ID, + "Alice Müller", 1900, 1980, + "Bob Müller", 1930, null, + RelationType.PARENT_OF, null, null, null); + when(relationshipService.getFamilyNetwork()) + .thenReturn(new NetworkDTO(List.of(node), List.of(edge))); + + mockMvc.perform(get("/api/network")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nodes[0].displayName").value("Alice Müller")) + .andExpect(jsonPath("$.edges[0].relationType").value("PARENT_OF")); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception { + PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true); + InferredRelationshipWithPersonDTO inferred = + new InferredRelationshipWithPersonDTO(relative, "Großvater", 2); + when(relationshipService.getInferredRelationships(PERSON_ID)) + .thenReturn(List.of(inferred)); + + mockMvc.perform(get("/api/persons/{id}/inferred-relationships", PERSON_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].label").value("Großvater")) + .andExpect(jsonPath("$[0].hops").value(2)); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) + void addRelationship_returns201_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception { + RelationshipDTO created = new RelationshipDTO( + UUID.randomUUID(), PERSON_ID, OTHER_ID, + "Alice Müller", null, null, + "Bob Müller", null, null, + RelationType.PARENT_OF, null, null, null); + when(relationshipService.addRelationship(any(), any())).thenReturn(created); + + mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.relationType").value("PARENT_OF")); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) + void deleteRelationship_returns204_for_WRITE_ALL_user() throws Exception { + UUID relId = UUID.randomUUID(); + doNothing().when(relationshipService).deleteRelationship(any(), any()); + + mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId)) + .andExpect(status().isNoContent()); + } +} 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..a146b564 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java @@ -0,0 +1,353 @@ +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.relationship.dto.InferredRelationshipWithPersonDTO; +import org.raddatz.familienarchiv.service.PersonService; + +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.ArgumentMatchers.anyList; +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 PersonService personService; + @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(); + } + + // --- 19: findAllFor delegates person resolution to PersonService --- + @Test + void findAllFor_resolves_persons_via_PersonService() { + Person parent = person(); + Person child = person(); + givenEdges(parentOf(parent, child)); + when(personService.getAllById(anyList())).thenReturn(List.of(child)); + + List results = service.findAllFor(parent.getId()); + + assertThat(results).hasSize(1); + assertThat(results.get(0).person().displayName()).isEqualTo(child.getDisplayName()); + } + + // --- 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(); + } +} 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..003922ab --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceIntegrationTest.java @@ -0,0 +1,181 @@ +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 addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() { + // V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF) + // and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF. + relationshipService.addRelationship(alice.getId(), + new CreateRelationshipRequest(bob.getId(), "SPOUSE_OF", null, null, null)); + + var reverse = new CreateRelationshipRequest(alice.getId(), "SPOUSE_OF", null, null, null); + assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP); + } + + @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 new file mode 100644 index 00000000..0844a5a5 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipServiceTest.java @@ -0,0 +1,209 @@ +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 org.raddatz.familienarchiv.relationship.dto.NetworkDTO; + +import java.time.Instant; +import java.util.List; +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()).saveAndFlush(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.saveAndFlush(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()).saveAndFlush(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()).saveAndFlush(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.saveAndFlush(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); + } + + @Test + void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() { + // alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result). + // Two edges exist: alice→bob (both family) and alice→charlie (one non-family). + // Only the alice→bob edge must appear in the returned NetworkDTO. + UUID aliceBobRelId = UUID.randomUUID(); + UUID aliceCharlieRelId = UUID.randomUUID(); + PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId); + PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId); + + when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob)); + when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie)); + + NetworkDTO result = service.getFamilyNetwork(); + + assertThat(result.nodes()).hasSize(2); + assertThat(result.edges()).hasSize(1); + assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId()); + assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId()); + } + + // --- 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(); + } +} diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 00000000..551a1f34 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,197 @@ +# Frontend — Familienarchiv + +## Overview + +SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows. + +## Tech Stack + +- **Framework**: SvelteKit 2 with Svelte 5 (runes mode) +- **Language**: TypeScript 5.9 +- **Styling**: Tailwind CSS 4.1 + custom brand utilities +- **Build Tool**: Vite 7 +- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static) +- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish +- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec) +- **PDF Rendering**: `pdfjs-dist` (PDF.js) +- **Testing**: + - Unit/Server: Vitest 4 (Node environment) + - Component: Vitest Browser Mode with Playwright (Chromium) + - E2E: Playwright (`frontend/e2e/`) + +## Project Structure + +``` +src/ +├── routes/ # SvelteKit file-based routing +│ ├── +layout.svelte # Global layout: header, nav, auth state +│ ├── +layout.server.ts # Loads current user, injects auth cookie +│ ├── +page.svelte # Home / document search dashboard +│ ├── documents/ # Document CRUD, detail, edit, upload +│ ├── persons/ # Person directory, detail, edit, merge +│ ├── briefwechsel/ # Bilateral conversation timeline +│ ├── chronik/ # Unified activity feed +│ ├── admin/ # User, group, tag, OCR, system management +│ ├── api/ # Internal API proxies (server-side only) +│ ├── login/ logout/ # Auth pages +│ └── ... +├── lib/ +│ ├── components/ # Reusable Svelte components +│ │ ├── document/ # Document-specific components +│ │ ├── chronik/ # Activity feed components +│ │ └── user/ # User-related components +│ ├── generated/ # Auto-generated API types (openapi-typescript) +│ ├── server/ # Server-only utilities (db, auth helpers) +│ ├── services/ # Client-side service logic +│ ├── stores/ # Svelte stores (global state) +│ ├── types.ts # Shared TypeScript types +│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode) +│ ├── api.server.ts # Typed API client factory +│ ├── utils.ts # Shared utilities +│ ├── relativeTime.ts # Time formatting +│ ├── search.ts # Search utilities +│ └── paraglide/ # Generated i18n code +├── hooks/ # SvelteKit hooks (handle, handleFetch) +└── actions/ # Custom Svelte actions (click outside, etc.) +``` + +## API Client Pattern + +All server-side API calls use the typed client from `$lib/api.server.ts`: + +```typescript +const api = createApiClient(fetch); +const result = await api.GET('/api/persons/{id}', { params: { path: { id } } }); + +// Always check via response.ok, NOT result.error +if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); +} +return { person: result.data! }; +``` + +Key rules: + +- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined) +- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code +- Use `result.data!` after an ok check + +For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`. + +## Form Actions Pattern + +```typescript +// +page.server.ts +export const actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + // ... + return fail(400, { error: 'message' }); // on error + throw redirect(303, '/target'); // on success + } +}; +``` + +## Date Handling + +- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `` sends ISO to the backend. +- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one: + ```typescript + new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format( + new Date(doc.documentDate + 'T12:00:00') + ); + ``` + +## Styling Conventions (Tailwind CSS 4) + +Brand color utilities (defined in `layout.css`): + +| Class | Value | Usage | +| ------------ | --------- | -------------------------------- | +| `brand-navy` | `#002850` | Primary text, buttons, headers | +| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons | +| `brand-sand` | `#E4E2D7` | Page background, card borders | + +Typography: + +- `font-serif` (Merriweather) — body text, document titles, names +- `font-sans` (Montserrat) — labels, metadata, UI chrome + +Card pattern for content sections: + +```svelte +
+

Section

+ +
+``` + +## Key UI Components + +| Component | Props | Description | +| -------------------- | ---------------------------------------------------- | ------------------------------------- | +| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead | +| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector | +| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead | +| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay | +| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block | +| `DocumentTopBar` | `document` | Responsive document metadata header | + +## How to Run + +### Development + +```bash +cd frontend +npm install +npm run dev # Dev server on port 5173 (or 3000 if --port 3000) +``` + +### Build & Preview + +```bash +npm run build # Production build +npm run preview # Preview production build +``` + +### Code Quality + +```bash +npm run lint # Prettier + ESLint check +npm run format # Auto-fix formatting +npm run check # svelte-check (type checking) +``` + +### Testing + +```bash +npm run test # Vitest unit + server tests (headless) +npm run test:coverage # Coverage report (server project only) +npm run test:e2e # Playwright E2E tests +npm run test:e2e:headed # Playwright E2E with visible browser +npm run test:e2e:ui # Playwright UI mode +``` + +### Regenerate API Types + +Requires backend running with `--spring.profiles.active=dev`: + +```bash +npm run generate:api +``` + +## Vite Proxy + +During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`). + +## i18n (Paraglide) + +Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with: + +```bash +npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide +``` + +Or let the Vite plugin handle it automatically during dev/build. diff --git a/frontend/e2e/CLAUDE.md b/frontend/e2e/CLAUDE.md new file mode 100644 index 00000000..b3e4c4a4 --- /dev/null +++ b/frontend/e2e/CLAUDE.md @@ -0,0 +1,141 @@ +# E2E Tests — Familienarchiv + +## Overview + +End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO). + +## Tech Stack + +- **Test Runner**: Playwright (`@playwright/test`) +- **Browser**: Chromium (desktop) +- **Locale**: `de-DE` (ensures German language detection) +- **Auth**: Shared session cookie stored after setup + +## Project Structure + +``` +frontend/e2e/ +├── auth.setup.ts # Authentication setup — logs in and saves session +├── auth.spec.ts # Authentication flows (login, logout, register) +├── admin.spec.ts # Admin panel CRUD operations +├── annotations.spec.ts # Document annotation features +├── bottom-panel.spec.ts # Bottom panel / transcription panel +├── dashboard-*.spec.ts # Dashboard variants and screenshots +├── documents.spec.ts # Document upload, edit, search +├── focus-rings.spec.ts # Accessibility focus ring tests +├── header.spec.ts # Navigation header +├── history.spec.ts # Chronik / activity feed +├── korrespondenz.spec.ts # Correspondence timeline +├── lang.spec.ts # Language switching +├── password-reset.spec.ts # Password reset flow +├── permissions.spec.ts # Role-based access control +├── persons.spec.ts # Person directory CRUD +├── profile.spec.ts # User profile +├── theme.spec.ts # Dark/light mode +├── transcription.spec.ts # Transcription workflows +├── accessibility.spec.ts # Axe accessibility scans +├── fixtures/ # Test data fixtures +└── helpers/ # Test helper utilities +``` + +## Authentication Strategy + +Tests share auth state via a stored session cookie: + +1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json` +2. **Tests**: All test projects depend on `setup` and reuse the stored session + +This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`). + +## Configuration + +Config lives in `frontend/playwright.config.ts`: + +| Setting | Value | Notes | +| --------------- | ----------------------- | ------------------------------ | +| `testDir` | `./e2e` | Test file location | +| `fullyParallel` | `false` | Shared auth state | +| `workers` | `1` | Sequential execution | +| `screenshot` | `'on'` | Always capture | +| `video` | `'retain-on-failure'` | Keep on failure | +| `trace` | `'retain-on-failure'` | Keep on failure | +| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` | + +The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL. + +## How to Run + +### Prerequisites + +The full stack must be running (or the `webServer` config will start the frontend dev server): + +```bash +# Start infrastructure +docker-compose up -d + +# Ensure backend is healthy +curl http://localhost:8080/actuator/health +``` + +### Run E2E Tests + +```bash +cd frontend + +# Headless (CI mode) +npm run test:e2e + +# With visible browser +npm run test:e2e:headed + +# Interactive UI mode +npm run test:e2e:ui + +# Run a specific test file +npx playwright test documents.spec.ts + +# Run with a different base URL (e.g., docker frontend on 5173) +E2E_BASE_URL=http://localhost:5173 npx playwright test +``` + +## Writing New E2E Tests + +1. Create a new `.spec.ts` file in `frontend/e2e/` +2. Use the shared auth state (no manual login needed) +3. Use page object patterns or helper functions from `helpers/` +4. Add `test-data-id` attributes to components for stable selectors +5. Run with `--debug` or `--ui` to troubleshoot + +### Example Test Pattern + +```typescript +import { test, expect } from '@playwright/test'; + +test('user can create a document', async ({ page }) => { + await page.goto('/documents/new'); + await page.getByTestId('document-title').fill('Test Document'); + await page.getByTestId('save-button').click(); + await expect(page).toHaveURL(/\/documents\/[^/]+$/); +}); +``` + +## Accessibility Testing + +`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test. + +```bash +npx playwright test accessibility.spec.ts +``` + +## Troubleshooting + +| Issue | Solution | +| --------------------- | ---------------------------------------- | +| Auth failures | Delete `e2e/.auth/user.json` and re-run | +| Backend not reachable | Ensure `docker-compose up -d` is running | +| Flaky tests | Increase timeout or add explicit waits | +| Screenshots missing | Check `test-results/e2e/` | + +## CI Integration + +E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first. diff --git a/frontend/e2e/stammbaum.spec.ts b/frontend/e2e/stammbaum.spec.ts new file mode 100644 index 00000000..ed414efb --- /dev/null +++ b/frontend/e2e/stammbaum.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; + +// Tests skipped until Playwright Chromium is installed in CI — see issue #363. +test.describe('Stammbaum — issue #358', () => { + test.skip(); + + test('nav swap: /briefwechsel still renders without 404', async ({ page }) => { + // Plan journey 4: the /briefwechsel route must stay intact even though the + // AppNav now points at /stammbaum. + const response = await page.goto('/briefwechsel'); + expect(response?.status()).toBeLessThan(400); + await expect(page).toHaveURL(/\/briefwechsel/); + }); + + test('/stammbaum renders the page heading', async ({ page }) => { + await page.goto('/stammbaum'); + await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible(); + }); + + test('/stammbaum either shows an empty state or at least one node', async ({ page }) => { + // Plan journey 3 (empty branch) and journey 1 (populated branch) covered jointly: + // the test passes whenever the page renders one of the two coherent states. + await page.goto('/stammbaum'); + const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' }); + const anyNode = page.locator('svg[role="img"][aria-label="Stammbaum"] g[role="button"]'); + await expect(async () => { + const emptyVisible = await empty.isVisible().catch(() => false); + const nodeCount = await anyNode.count(); + expect(emptyVisible || nodeCount > 0).toBe(true); + }).toPass(); + + if (await empty.isVisible().catch(() => false)) { + await expect(page.getByRole('link', { name: /Zur Personenliste/ })).toBeVisible(); + } + }); + + test('person edit Stammbaum card surfaces the year-range error', async ({ page }) => { + // Plan task 36: Bis < Von triggers the inline error and keeps the form unsubmitted. + // We pick the first person, open the edit page, expand the add-rel form, and + // inspect the validation message bound to the Bis field. + await page.goto('/persons'); + const firstPerson = page.locator('a[href^="/persons/"]').first(); + await firstPerson.click(); + await expect(page).toHaveURL(/\/persons\/[^/]+/); + await page.goto(page.url() + '/edit'); + + // Open the add-rel form + const addBtn = page.getByRole('button', { name: /Beziehung hinzufügen/i }); + await addBtn.click(); + + // Enter Von 1935, Bis 1920 → expect the year-range error + const fromInput = page.locator('input[name="fromYear"]'); + const toInput = page.locator('input[name="toYear"]'); + await fromInput.fill('1935'); + await toInput.fill('1920'); + + await expect(page.locator('#add-rel-year-error')).toBeVisible(); + await expect(page.locator('#add-rel-year-error')).toContainText(/Bis.*Von|nicht vor/i); + }); +}); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7fa3867e..f8cf9f8c 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -907,5 +907,80 @@ "bulk_edit_loading": "Dokumente werden geladen…", "bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.", "bulk_edit_topbar_title": "Massenbearbeitung", - "bulk_edit_count_pill": "{count} werden bearbeitet" + "bulk_edit_count_pill": "{count} werden bearbeitet", + + "nav_stammbaum": "Stammbaum", + + "error_relationship_not_found": "Die Beziehung wurde nicht gefunden.", + "error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.", + "error_duplicate_relationship": "Diese Beziehung gibt es bereits.", + + "relation_parent_of": "Elternteil von", + "relation_child_of": "Kind von", + "relation_spouse_of": "Ehegatte", + "relation_sibling_of": "Geschwister", + "relation_friend": "Freund", + "relation_colleague": "Kollege", + "relation_employer": "Arbeitgeber", + "relation_doctor": "Arzt", + "relation_neighbor": "Nachbar", + "relation_other": "Sonstige", + + "relation_inferred_parent": "Elternteil", + "relation_inferred_child": "Kind", + "relation_inferred_spouse": "Ehegatte", + "relation_inferred_sibling": "Geschwister", + "relation_inferred_grandparent": "Großelternteil", + "relation_inferred_grandchild": "Enkelkind", + "relation_inferred_great_grandparent": "Urgroßelternteil", + "relation_inferred_great_grandchild": "Urenkel", + "relation_inferred_uncle_aunt": "Onkel/Tante", + "relation_inferred_niece_nephew": "Nichte/Neffe", + "relation_inferred_great_uncle_aunt": "Großonkel/Großtante", + "relation_inferred_great_niece_nephew": "Großnichte/Großneffe", + "relation_inferred_inlaw_parent": "Schwiegerelternteil", + "relation_inferred_inlaw_child": "Schwiegerkind", + "relation_inferred_sibling_inlaw": "Schwager/Schwägerin", + "relation_inferred_cousin_1": "Cousin/Cousine", + "relation_inferred_distant": "Weitläufige Verwandtschaft", + + "doc_details_field_relationship": "Verwandtschaft", + + "stammbaum_empty_heading": "Noch keine Familienmitglieder", + "stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.", + "stammbaum_empty_link": "→ Zur Personenliste", + "stammbaum_panel_direct_rels": "Direkte Beziehungen", + "stammbaum_panel_derived_rels": "Abgeleitete Beziehungen", + "stammbaum_panel_to_person": "Zur Personenseite →", + "stammbaum_panel_add_rel": "+ Beziehung hinzufügen", + "stammbaum_relationships_heading": "Stammbaum & Beziehungen", + "stammbaum_zoom_in": "Vergrößern", + "stammbaum_zoom_out": "Verkleinern", + "stammbaum_generations": "Generationen", + + "relation_error_duplicate": "Diese Beziehung gibt es bereits.", + "relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.", + "relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.", + "relation_year_from": "ab {year}", + "relation_year_to": "bis {year}", + "relation_year_error_bis_before_von": "Bis-Jahr darf nicht vor Von-Jahr liegen.", + "relation_label_family_member": "Als Familienmitglied", + "relation_toggle_add_to_tree": "Zum Stammbaum hinzufügen", + "relation_toggle_remove_from_tree": "Aus Stammbaum entfernen", + "relation_label_in_tree": "Erscheint im Stammbaum", + "relation_label_view_in_tree": "Ansehen →", + "relation_label_direct": "Direkte Beziehungen", + "relation_label_derived": "Abgeleitete Beziehungen", + "relation_btn_add": "Hinzufügen", + "relation_btn_save": "Speichern", + "relation_btn_cancel": "Abbrechen", + "relation_form_group_family": "Familie", + "relation_form_group_social": "Sozial", + "relation_form_field_type": "Typ", + "relation_form_field_from_year": "Von Jahr", + "relation_form_field_to_year": "Bis Jahr", + "relation_form_year_placeholder": "z.B. 1920", + + "person_relationships_heading": "Beziehungen", + "person_relationships_empty": "Noch keine Beziehungen bekannt." } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f718d543..afa6adfa 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -907,5 +907,80 @@ "bulk_edit_loading": "Loading documents…", "bulk_edit_all_x_failed": "Could not load filter results — please retry.", "bulk_edit_topbar_title": "Bulk edit", - "bulk_edit_count_pill": "{count} will be edited" + "bulk_edit_count_pill": "{count} will be edited", + + "nav_stammbaum": "Family tree", + + "error_relationship_not_found": "Relationship not found.", + "error_circular_relationship": "This relationship would form a cycle.", + "error_duplicate_relationship": "This relationship already exists.", + + "relation_parent_of": "Parent of", + "relation_child_of": "Child of", + "relation_spouse_of": "Spouse", + "relation_sibling_of": "Sibling", + "relation_friend": "Friend", + "relation_colleague": "Colleague", + "relation_employer": "Employer", + "relation_doctor": "Doctor", + "relation_neighbor": "Neighbour", + "relation_other": "Other", + + "relation_inferred_parent": "Parent", + "relation_inferred_child": "Child", + "relation_inferred_spouse": "Spouse", + "relation_inferred_sibling": "Sibling", + "relation_inferred_grandparent": "Grandparent", + "relation_inferred_grandchild": "Grandchild", + "relation_inferred_great_grandparent": "Great-grandparent", + "relation_inferred_great_grandchild": "Great-grandchild", + "relation_inferred_uncle_aunt": "Uncle/Aunt", + "relation_inferred_niece_nephew": "Niece/Nephew", + "relation_inferred_great_uncle_aunt": "Great-uncle/Aunt", + "relation_inferred_great_niece_nephew": "Great-niece/Nephew", + "relation_inferred_inlaw_parent": "Parent-in-law", + "relation_inferred_inlaw_child": "Child-in-law", + "relation_inferred_sibling_inlaw": "Sibling-in-law", + "relation_inferred_cousin_1": "Cousin", + "relation_inferred_distant": "Distant relative", + + "doc_details_field_relationship": "Relationship", + + "stammbaum_empty_heading": "No family members yet", + "stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.", + "stammbaum_empty_link": "→ Go to person list", + "stammbaum_panel_direct_rels": "Direct relationships", + "stammbaum_panel_derived_rels": "Derived relationships", + "stammbaum_panel_to_person": "Go to person page →", + "stammbaum_panel_add_rel": "+ Add relationship", + "stammbaum_relationships_heading": "Family tree & relationships", + "stammbaum_zoom_in": "Zoom in", + "stammbaum_zoom_out": "Zoom out", + "stammbaum_generations": "Generations", + + "relation_error_duplicate": "This relationship already exists.", + "relation_error_circular": "This relationship would form a cycle.", + "relation_error_self": "A person cannot be related to themselves.", + "relation_year_from": "from {year}", + "relation_year_to": "until {year}", + "relation_year_error_bis_before_von": "End year must not precede start year.", + "relation_label_family_member": "Family member", + "relation_toggle_add_to_tree": "Add to family tree", + "relation_toggle_remove_from_tree": "Remove from family tree", + "relation_label_in_tree": "Appears in the family tree", + "relation_label_view_in_tree": "View →", + "relation_label_direct": "Direct relationships", + "relation_label_derived": "Derived relationships", + "relation_btn_add": "Add", + "relation_btn_save": "Save", + "relation_btn_cancel": "Cancel", + "relation_form_group_family": "Family", + "relation_form_group_social": "Social", + "relation_form_field_type": "Type", + "relation_form_field_from_year": "From year", + "relation_form_field_to_year": "To year", + "relation_form_year_placeholder": "e.g. 1920", + + "person_relationships_heading": "Relationships", + "person_relationships_empty": "No relationships known yet." } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 86837a8f..87d26d83 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -907,5 +907,80 @@ "bulk_edit_loading": "Cargando documentos…", "bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.", "bulk_edit_topbar_title": "Edición masiva", - "bulk_edit_count_pill": "Se editarán {count}" + "bulk_edit_count_pill": "Se editarán {count}", + + "nav_stammbaum": "Árbol genealógico", + + "error_relationship_not_found": "La relación no fue encontrada.", + "error_circular_relationship": "Esta relación crearía un ciclo.", + "error_duplicate_relationship": "Esta relación ya existe.", + + "relation_parent_of": "Progenitor de", + "relation_child_of": "Hijo/a de", + "relation_spouse_of": "Cónyuge", + "relation_sibling_of": "Hermano/a", + "relation_friend": "Amigo/a", + "relation_colleague": "Colega", + "relation_employer": "Empleador", + "relation_doctor": "Médico", + "relation_neighbor": "Vecino/a", + "relation_other": "Otro", + + "relation_inferred_parent": "Progenitor", + "relation_inferred_child": "Hijo/a", + "relation_inferred_spouse": "Cónyuge", + "relation_inferred_sibling": "Hermano/a", + "relation_inferred_grandparent": "Abuelo/a", + "relation_inferred_grandchild": "Nieto/a", + "relation_inferred_great_grandparent": "Bisabuelo/a", + "relation_inferred_great_grandchild": "Bisnieto/a", + "relation_inferred_uncle_aunt": "Tío/Tía", + "relation_inferred_niece_nephew": "Sobrino/a", + "relation_inferred_great_uncle_aunt": "Tío/a abuelo/a", + "relation_inferred_great_niece_nephew": "Sobrino/a nieto/a", + "relation_inferred_inlaw_parent": "Suegro/a", + "relation_inferred_inlaw_child": "Yerno/Nuera", + "relation_inferred_sibling_inlaw": "Cuñado/a", + "relation_inferred_cousin_1": "Primo/a", + "relation_inferred_distant": "Pariente lejano", + + "doc_details_field_relationship": "Parentesco", + + "stammbaum_empty_heading": "Aún no hay miembros de la familia", + "stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.", + "stammbaum_empty_link": "→ Ir a la lista de personas", + "stammbaum_panel_direct_rels": "Relaciones directas", + "stammbaum_panel_derived_rels": "Relaciones derivadas", + "stammbaum_panel_to_person": "Ir a la persona →", + "stammbaum_panel_add_rel": "+ Añadir relación", + "stammbaum_relationships_heading": "Árbol genealógico & relaciones", + "stammbaum_zoom_in": "Acercar", + "stammbaum_zoom_out": "Alejar", + "stammbaum_generations": "Generaciones", + + "relation_error_duplicate": "Esta relación ya existe.", + "relation_error_circular": "Esta relación crearía un ciclo.", + "relation_error_self": "Una persona no puede estar relacionada consigo misma.", + "relation_year_from": "desde {year}", + "relation_year_to": "hasta {year}", + "relation_year_error_bis_before_von": "El año final no puede ser anterior al año inicial.", + "relation_label_family_member": "Miembro de la familia", + "relation_toggle_add_to_tree": "Añadir al árbol genealógico", + "relation_toggle_remove_from_tree": "Quitar del árbol genealógico", + "relation_label_in_tree": "Aparece en el árbol genealógico", + "relation_label_view_in_tree": "Ver →", + "relation_label_direct": "Relaciones directas", + "relation_label_derived": "Relaciones derivadas", + "relation_btn_add": "Añadir", + "relation_btn_save": "Guardar", + "relation_btn_cancel": "Cancelar", + "relation_form_group_family": "Familia", + "relation_form_group_social": "Social", + "relation_form_field_type": "Tipo", + "relation_form_field_from_year": "Desde año", + "relation_form_field_to_year": "Hasta año", + "relation_form_year_placeholder": "ej. 1920", + + "person_relationships_heading": "Relaciones", + "person_relationships_empty": "Aún no se conocen relaciones." } diff --git a/frontend/src/lib/components/AddRelationshipForm.svelte b/frontend/src/lib/components/AddRelationshipForm.svelte new file mode 100644 index 00000000..7c5ee2e4 --- /dev/null +++ b/frontend/src/lib/components/AddRelationshipForm.svelte @@ -0,0 +1,202 @@ + + +{#snippet formFields()} +
+ +
+ +
+ + +
+ {#if selfError} + + {/if} + {#if callbackError} + + {/if} +
+ + +
+{/snippet} + +{#if !open} + +{:else if onSubmit} +
+ {@render formFields()} +
+{:else} +
{ + return async ({ result, update }) => { + await update(); + if (result.type === 'success') { + open = false; + reset(); + } + }; + }} + class="mt-3 rounded-sm border border-line bg-muted/40 p-3" + > + {@render formFields()} +
+{/if} diff --git a/frontend/src/lib/components/AddRelationshipForm.svelte.spec.ts b/frontend/src/lib/components/AddRelationshipForm.svelte.spec.ts new file mode 100644 index 00000000..f542d7de --- /dev/null +++ b/frontend/src/lib/components/AddRelationshipForm.svelte.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import AddRelationshipForm from './AddRelationshipForm.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); +vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null })); + +afterEach(cleanup); + +describe('AddRelationshipForm', () => { + it('shows add-relationship button initially and no form', async () => { + render(AddRelationshipForm, { personId: 'person-1' }); + await expect.element(page.getByRole('button')).toBeInTheDocument(); + await expect.element(page.getByRole('combobox')).not.toBeInTheDocument(); + }); + + it('shows relationType select when add button is clicked', async () => { + render(AddRelationshipForm, { personId: 'person-1' }); + document.querySelector('button')!.click(); + await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + }); + + it('hides form and shows button when cancel is clicked', async () => { + render(AddRelationshipForm, { personId: 'person-1' }); + document.querySelector('button')!.click(); + await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + const cancelBtn = [...document.querySelectorAll('button')].find( + (b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '') + ); + cancelBtn!.click(); + await expect.element(page.getByRole('combobox')).not.toBeInTheDocument(); + }); + + it('submit is disabled when no person is selected', async () => { + render(AddRelationshipForm, { personId: 'person-1' }); + document.querySelector('button')!.click(); + await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled(); + }); + + it('form has no server action when onSubmit prop is provided', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(AddRelationshipForm, { personId: 'person-1', onSubmit }); + document.querySelector('button')!.click(); + await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + const form = document.querySelector('form'); + expect(form?.hasAttribute('action')).toBe(false); + }); + + it('shows year-range error when toYear is before fromYear', async () => { + render(AddRelationshipForm, { personId: 'person-1' }); + document.querySelector('button')!.click(); + await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + + const fromInput = document.querySelector('input[name="fromYear"]')!; + fromInput.value = '1935'; + fromInput.dispatchEvent(new InputEvent('input', { bubbles: true })); + + const toInput = document.querySelector('input[name="toYear"]')!; + toInput.value = '1920'; + toInput.dispatchEvent(new InputEvent('input', { bubbles: true })); + + await expect.element(page.getByRole('alert')).toBeVisible(); + }); +}); diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte b/frontend/src/lib/components/DocumentMetadataDrawer.svelte index a24a27d1..ff8e177c 100644 --- a/frontend/src/lib/components/DocumentMetadataDrawer.svelte +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte @@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js'; import { formatDate } from '$lib/utils/date'; import { formatDocumentStatus } from '$lib/utils/documentStatusLabel'; import { getInitials, personAvatarColor } from '$lib/utils/personFormat'; +import RelationshipPill from '$lib/components/RelationshipPill.svelte'; type Person = { id: string; firstName?: string | null; lastName: string; displayName: string }; type Tag = { id: string; name: string }; @@ -14,9 +15,18 @@ type Props = { sender: Person | null; receivers: Person[]; tags: Tag[]; + inferredRelationship?: { labelFromA: string; labelFromB: string } | null; }; -let { documentDate, location, status, sender, receivers, tags }: Props = $props(); +let { + documentDate, + location, + status, + sender, + receivers, + tags, + inferredRelationship = null +}: Props = $props(); const VISIBLE_RECEIVER_LIMIT = 5; @@ -37,7 +47,7 @@ function getFullName(person: Person): string { } -{#snippet personCard(person: Person)} +{#snippet personCard(person: Person, relationLabel: string | null = null)} {getInitials(person.displayName)} - {getFullName(person)} + {getFullName(person)} + {#if relationLabel} + + {/if} {/snippet} @@ -88,7 +101,7 @@ function getFullName(person: Person): string {

{m.doc_details_field_sender()}

- {@render personCard(sender)} + {@render personCard(sender, inferredRelationship?.labelFromA ?? null)} {/if} {#if receivers.length > 0} @@ -97,8 +110,16 @@ function getFullName(person: Person): string { {m.doc_details_field_receivers()}

- {#each displayedReceivers as receiver (receiver.id)} - {@render personCard(receiver)} + {#each displayedReceivers as receiver, i (receiver.id)} + {@render personCard( + receiver, + // Badge only shown when there is exactly one receiver — with multiple + // receivers the inferred label is computed from the sender's viewpoint + // and cannot be attributed to a specific receiver. + i === 0 && receivers.length === 1 + ? (inferredRelationship?.labelFromB ?? null) + : null + )} {/each}
{#if hiddenReceiverCount > 0 && !showAllReceivers} diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts index aeda6bbe..07f3d4c5 100644 --- a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts @@ -81,6 +81,25 @@ describe('DocumentMetadataDrawer — persons column', () => { renderDrawer({ sender: null, receivers: [] }); await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument(); }); + + it('renders inferred relationship pills inline next to sender and receiver', async () => { + renderDrawer({ + receivers: [receivers[0]], + inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' } + }); + + // Sender link contains its pill, receiver link contains its pill. + const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i }); + await expect.element(senderLink).toBeInTheDocument(); + const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i }); + await expect.element(receiverLink).toBeInTheDocument(); + }); + + it('omits the pills when no inferred relationship is provided', async () => { + renderDrawer(); + const elternteil = page.getByText('Elternteil'); + expect(await elternteil.elements()).toHaveLength(0); + }); }); // ─── Tags column ───────────────────────────────────────────────────────────── diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index f352364a..15cadd6b 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -30,9 +30,16 @@ type Props = { canWrite: boolean; fileUrl: string; transcribeMode: boolean; + inferredRelationship?: { labelFromA: string; labelFromB: string } | null; }; -let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props(); +let { + doc, + canWrite, + fileUrl, + transcribeMode = $bindable(), + inferredRelationship = null +}: Props = $props(); let detailsOpen = $state(false); @@ -275,6 +282,7 @@ let mobileMenuOpen = $state(false); sender={doc.sender ?? null} receivers={doc.receivers ? [...doc.receivers] : []} tags={doc.tags ? [...doc.tags] : []} + inferredRelationship={inferredRelationship} /> {/if} 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/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 954f23dd..55acd69e 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -19,6 +19,7 @@ interface Props { autofocus?: boolean; required?: boolean; restrictToCorrespondentsOf?: string; + excludePersonId?: string; badge?: 'additive' | 'replace'; onchange?: (value: string) => void; onfocused?: () => void; @@ -36,6 +37,7 @@ let { autofocus = false, required = false, restrictToCorrespondentsOf, + excludePersonId, badge, onchange, onfocused @@ -61,17 +63,20 @@ $effect(() => { const typeahead = createTypeahead({ fetchUrl: async (term) => { const personId = restrictToCorrespondentsOf; + const excludeId = excludePersonId; + const filter = (results: Person[]) => + excludeId ? results.filter((p) => p.id !== excludeId) : results; if (personId) { const url = term.length >= 1 ? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}` : `/api/persons/${personId}/correspondents`; const res = await fetch(url); - return res.ok ? await res.json() : []; + return res.ok ? filter(await res.json()) : []; } if (term.length < 1) return []; const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`); - return res.ok ? await res.json() : []; + return res.ok ? filter(await res.json()) : []; }, debounceMs: 300 }); diff --git a/frontend/src/lib/components/RelationshipChip.svelte b/frontend/src/lib/components/RelationshipChip.svelte new file mode 100644 index 00000000..852b0aaa --- /dev/null +++ b/frontend/src/lib/components/RelationshipChip.svelte @@ -0,0 +1,49 @@ + + +
  • + + {chipLabel} + + + {otherName} + + {#if yearRange} + {yearRange} + {/if} + {#if canWrite} +
    + + +
    + {/if} +
  • diff --git a/frontend/src/lib/components/RelationshipChip.svelte.spec.ts b/frontend/src/lib/components/RelationshipChip.svelte.spec.ts new file mode 100644 index 00000000..9c85a6a6 --- /dev/null +++ b/frontend/src/lib/components/RelationshipChip.svelte.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import RelationshipChip from './RelationshipChip.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +afterEach(cleanup); + +const baseProps = { + chipLabel: 'Elternteil', + otherName: 'Anna Schmidt', + yearRange: '', + canWrite: false, + relId: 'rel-1' +}; + +describe('RelationshipChip', () => { + it('renders the chip label', async () => { + render(RelationshipChip, baseProps); + await expect.element(page.getByText('Elternteil')).toBeInTheDocument(); + }); + + it('renders the other person name', async () => { + render(RelationshipChip, baseProps); + await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); + }); + + it('shows year range when provided', async () => { + render(RelationshipChip, { ...baseProps, yearRange: '1920–1980' }); + await expect.element(page.getByText('1920–1980')).toBeInTheDocument(); + }); + + it('does not show year range span when empty', async () => { + render(RelationshipChip, { ...baseProps, yearRange: '' }); + expect(document.querySelector('[data-testid="year-range"]')).toBeNull(); + }); + + it('shows delete button when canWrite is true', async () => { + render(RelationshipChip, { ...baseProps, canWrite: true }); + await expect.element(page.getByRole('button')).toBeInTheDocument(); + }); + + it('hides delete button when canWrite is false', async () => { + render(RelationshipChip, { ...baseProps, canWrite: false }); + expect(document.querySelector('button')).toBeNull(); + }); + + it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => { + render(RelationshipChip, { ...baseProps, canWrite: true }); + const btn = document.querySelector('button')!; + expect(btn.className).toContain('h-11'); + expect(btn.className).toContain('w-11'); + }); +}); diff --git a/frontend/src/lib/components/RelationshipPill.svelte b/frontend/src/lib/components/RelationshipPill.svelte new file mode 100644 index 00000000..d447b326 --- /dev/null +++ b/frontend/src/lib/components/RelationshipPill.svelte @@ -0,0 +1,10 @@ + + + + {label} + diff --git a/frontend/src/lib/components/StammbaumCard.svelte b/frontend/src/lib/components/StammbaumCard.svelte new file mode 100644 index 00000000..49742ceb --- /dev/null +++ b/frontend/src/lib/components/StammbaumCard.svelte @@ -0,0 +1,174 @@ + + +
    + +
    +

    + {m.stammbaum_relationships_heading()} +

    + {#if canWrite} +
    + + +
    + {/if} +
    + + {#if relationshipError} + + {/if} + + + {#if familyMember} +
    +
    + + {m.relation_label_in_tree()} +
    + + {m.relation_label_view_in_tree()} + +
    + {/if} + + +

    + {m.relation_label_direct()} +

    + {#if sortedDirect.length === 0} +

    {m.person_relationships_empty()}

    + {:else} +
      + {#each sortedDirect as rel (rel.id)} + + {/each} +
    + {/if} + + {#if canWrite} + + {/if} + + + {#if topDerived.length > 0} +
    + + {m.relation_label_derived()} + + +
    + {/if} +
    diff --git a/frontend/src/lib/components/StammbaumCard.svelte.spec.ts b/frontend/src/lib/components/StammbaumCard.svelte.spec.ts new file mode 100644 index 00000000..da9271f9 --- /dev/null +++ b/frontend/src/lib/components/StammbaumCard.svelte.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import StammbaumCard from './StammbaumCard.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); +vi.mock('$lib/components/RelationshipChip.svelte', () => ({ default: () => null })); +vi.mock('$lib/components/AddRelationshipForm.svelte', () => ({ default: () => null })); + +afterEach(cleanup); + +const baseProps = { + personId: 'person-1', + familyMember: false, + relationships: [], + inferredRelationships: [], + canWrite: false +}; + +describe('StammbaumCard', () => { + it('renders the section heading', async () => { + render(StammbaumCard, baseProps); + await expect.element(page.getByText('Stammbaum & Beziehungen')).toBeInTheDocument(); + }); + + it('shows empty-relationships message when relationships list is empty', async () => { + render(StammbaumCard, baseProps); + await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); + }); + + it('renders the family-member toggle when canWrite is true', async () => { + render(StammbaumCard, { ...baseProps, canWrite: true }); + await expect.element(page.getByText('Als Familienmitglied')).toBeInTheDocument(); + }); + + it('displays relationshipError text when provided', async () => { + render(StammbaumCard, { ...baseProps, relationshipError: 'Test Fehler' }); + await expect.element(page.getByText('Test Fehler')).toBeInTheDocument(); + }); + + it('toggle aria-label says "Zum Stammbaum hinzufügen" when not yet a family member', async () => { + render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: false }); + await expect + .element(page.getByRole('switch', { name: 'Zum Stammbaum hinzufügen' })) + .toBeInTheDocument(); + }); + + it('toggle aria-label says "Aus Stammbaum entfernen" when already a family member', async () => { + render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: true }); + await expect + .element(page.getByRole('switch', { name: 'Aus Stammbaum entfernen' })) + .toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/StammbaumSidePanel.svelte b/frontend/src/lib/components/StammbaumSidePanel.svelte new file mode 100644 index 00000000..5ec163ac --- /dev/null +++ b/frontend/src/lib/components/StammbaumSidePanel.svelte @@ -0,0 +1,183 @@ + + +
    +
    +
    +

    {node.displayName}

    + {#if node.birthYear || node.deathYear} +

    + {node.birthYear ?? '?'}–{node.deathYear ?? ''} +

    + {/if} +
    + +
    + + {#if error} + + {:else if loading} +

    + {:else} +
    +

    + {m.stammbaum_panel_direct_rels()} +

    + {#if directRels.length === 0} +

    {m.person_relationships_empty()}

    + {:else} +
      + {#each directRels as rel (rel.id)} +
    • + + {chipLabel(rel, node.id)} + + + {otherName(rel, node.id)} + +
    • + {/each} +
    + {/if} + + {#if canWrite} + {#key node.id} + + {/key} + {/if} +
    + + {#if topDerived.length > 0} +
    +

    + {m.stammbaum_panel_derived_rels()} +

    +
      + {#each topDerived as derived (derived.person.id)} +
    • + + {inferredRelationshipLabel(derived.label)} + + + {derived.person.displayName} + +
    • + {/each} +
    +
    + {/if} + {/if} + + +
    diff --git a/frontend/src/lib/components/StammbaumSidePanel.svelte.spec.ts b/frontend/src/lib/components/StammbaumSidePanel.svelte.spec.ts new file mode 100644 index 00000000..1006367b --- /dev/null +++ b/frontend/src/lib/components/StammbaumSidePanel.svelte.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import StammbaumSidePanel from './StammbaumSidePanel.svelte'; + +vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() })); +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); +vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null })); + +const makeNode = () => ({ + id: 'person-1', + displayName: 'Alice Müller', + birthYear: 1900, + deathYear: null, + familyMember: true +}); + +function stubFetch(directRels: unknown[] = [], inferredRels: unknown[] = []) { + let callCount = 0; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + const body = callCount % 2 === 1 ? directRels : inferredRels; + return Promise.resolve({ ok: true, json: () => Promise.resolve(body) }); + }) + ); +} + +beforeEach(() => stubFetch()); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('StammbaumSidePanel', () => { + it('calls onClose when dismiss button is clicked', async () => { + const onClose = vi.fn(); + render(StammbaumSidePanel, { node: makeNode(), onClose, canWrite: false }); + await expect.element(page.getByRole('button', { name: 'Schließen' })).toBeInTheDocument(); + const btn = [...document.querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === 'Schließen' + ); + btn!.click(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('renders the person displayName as heading', async () => { + render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false }); + await expect.element(page.getByText('Alice Müller')).toBeInTheDocument(); + }); + + it('shows empty-relationships message when no direct relationships are loaded', async () => { + render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false }); + await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); + }); + + it('year inputs inside the add form have label elements (canWrite=true)', async () => { + render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true }); + await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); + const addBtn = [...document.querySelectorAll('button')].find((b) => + /Beziehung hinzufügen/i.test(b.textContent ?? '') + ); + addBtn!.click(); + await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + const yearInputs = [...document.querySelectorAll('input')].filter( + (i) => i.inputMode === 'numeric' + ); + expect(yearInputs.length).toBeGreaterThan(0); + for (const input of yearInputs) { + expect(input.closest('label')).not.toBeNull(); + } + }); + + it('shows loading indicator while fetching', async () => { + let resolveFirst: (v: unknown) => void; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + ); + render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false }); + await expect.element(page.getByText('…')).toBeInTheDocument(); + resolveFirst!({ ok: true, json: () => Promise.resolve([]) }); + }); +}); diff --git a/frontend/src/lib/components/StammbaumTree.svelte b/frontend/src/lib/components/StammbaumTree.svelte new file mode 100644 index 00000000..f28857ac --- /dev/null +++ b/frontend/src/lib/components/StammbaumTree.svelte @@ -0,0 +1,548 @@ + + + + + {#each parentLinks.shared as group (group.key)} + {@const aCenter = nodeCenter(group.parentA)} + {@const bCenter = nodeCenter(group.parentB)} + {@const childCenters = group.childIds + .map((id) => nodeCenter(id)) + .filter((c): c is { x: number; y: number } => c !== null)} + {#if aCenter && bCenter && childCenters.length > 0} + {@const midX = (aCenter.x + bCenter.x) / 2} + {@const parentBottomY = aCenter.y + NODE_H / 2} + {@const childTopY = childCenters[0].y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + {@const xs = childCenters.map((c) => c.x)} + {@const minX = Math.min(midX, ...xs)} + {@const maxX = Math.max(midX, ...xs)} + + {#if minX !== maxX} + + {/if} + {#each childCenters as cc, i (group.childIds[i])} + + {/each} + {/if} + {/each} + + + {#each parentLinks.single as link (link.key)} + {@const parentCenter = nodeCenter(link.parentId)} + {@const childCenter = nodeCenter(link.childId)} + {#if parentCenter && childCenter} + {@const parentBottomY = parentCenter.y + NODE_H / 2} + {@const childTopY = childCenter.y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + + {#if parentCenter.x !== childCenter.x} + + {/if} + + {/if} + {/each} + + + {#each spouseEdges as e (e.id)} + {@const aCenter = nodeCenter(e.personId)} + {@const bCenter = nodeCenter(e.relatedPersonId)} + {#if aCenter && bCenter} + + + {/if} + {/each} + + + {#each nodes as node (node.id)} + {@const pos = layout.positions.get(node.id)} + {#if pos} + {@const isSelected = selectedId === node.id} + {@const isFocused = focusedId === node.id} + onSelect(node.id)} + onkeydown={(e) => handleNodeKey(e, node.id)} + onfocus={() => (focusedId = node.id)} + onblur={() => (focusedId = null)} + class="cursor-pointer focus:outline-none" + > + {#if isFocused} + + {/if} + + {#if isSelected} + + {/if} + + {node.displayName} + + {#if node.birthYear || node.deathYear} + + {node.birthYear ?? '?'}–{node.deathYear ?? ''} + + {/if} + + {/if} + {/each} + diff --git a/frontend/src/lib/components/StammbaumTree.svelte.test.ts b/frontend/src/lib/components/StammbaumTree.svelte.test.ts new file mode 100644 index 00000000..7bf0ef1b --- /dev/null +++ b/frontend/src/lib/components/StammbaumTree.svelte.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import StammbaumTree from './StammbaumTree.svelte'; + +const ID_A = '00000000-0000-0000-0000-000000000001'; +const ID_B = '00000000-0000-0000-0000-000000000002'; + +function parseViewBox(svg: SVGElement): [number, number, number, number] { + const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number); + return [parts[0], parts[1], parts[2], parts[3]]; +} + +function rectsCentroid(svg: SVGElement): { x: number; y: number } { + const rects = Array.from(svg.querySelectorAll('rect')); + let sx = 0; + let sy = 0; + let n = 0; + for (const r of rects) { + const x = parseFloat(r.getAttribute('x') ?? '0'); + const y = parseFloat(r.getAttribute('y') ?? '0'); + const w = parseFloat(r.getAttribute('width') ?? '0'); + const h = parseFloat(r.getAttribute('height') ?? '0'); + // Skip the narrow accent stripe. + if (w < 10) continue; + // Each node rect lives inside . + const g = r.closest('g[transform]'); + const transform = g?.getAttribute('transform') ?? ''; + const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); + const tx = match ? parseFloat(match[1]) : 0; + const ty = match ? parseFloat(match[2]) : 0; + sx += tx + x + w / 2; + sy += ty + y + h / 2; + n++; + } + return { x: sx / n, y: sy / n }; +} + +describe('StammbaumTree viewBox', () => { + it('uses the minimum size and centers a single node', async () => { + render(StammbaumTree, { + nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }], + edges: [], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const svg = document.querySelector('svg')!; + const [x, y, w, h] = parseViewBox(svg); + + // Single 160x56 node fits inside the 1200x800 minimum viewBox. + expect(w).toBe(1200); + expect(h).toBe(800); + + // Whatever absolute coordinates the layout uses, the viewBox must + // centre on the rendered content. + const c = rectsCentroid(svg); + expect(x + w / 2).toBeCloseTo(c.x, 1); + expect(y + h / 2).toBeCloseTo(c.y, 1); + }); + + it('renders only orthogonal segments when two parents share two children', async () => { + const PARENT_A = '00000000-0000-0000-0000-00000000000a'; + const PARENT_B = '00000000-0000-0000-0000-00000000000b'; + const CHILD_1 = '00000000-0000-0000-0000-00000000000c'; + const CHILD_2 = '00000000-0000-0000-0000-00000000000d'; + render(StammbaumTree, { + nodes: [ + { id: PARENT_A, displayName: 'Walter', familyMember: true }, + { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, + { id: CHILD_1, displayName: 'Clara', familyMember: true }, + { id: CHILD_2, displayName: 'Hans', familyMember: true } + ], + edges: [ + { + id: 'sp', + personId: PARENT_A, + relatedPersonId: PARENT_B, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Eugenie', + relationType: 'SPOUSE_OF' + }, + { + id: 'p1a', + personId: PARENT_A, + relatedPersonId: CHILD_1, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Clara', + relationType: 'PARENT_OF' + }, + { + id: 'p1b', + personId: PARENT_B, + relatedPersonId: CHILD_1, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Clara', + relationType: 'PARENT_OF' + }, + { + id: 'p2a', + personId: PARENT_A, + relatedPersonId: CHILD_2, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + }, + { + id: 'p2b', + personId: PARENT_B, + relatedPersonId: CHILD_2, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const lines = Array.from(document.querySelectorAll('svg line')); + // Every parent-child segment must be either vertical (x1==x2) or + // horizontal (y1==y2) — no slanted segments allowed. + const slanted = lines.filter( + (l) => + l.getAttribute('x1') !== l.getAttribute('x2') && + l.getAttribute('y1') !== l.getAttribute('y2') + ); + expect(slanted).toHaveLength(0); + // Sibling bar must exist and span the children: at least one + // horizontal line whose x1 != x2 and y1 == y2. + const horizontalBars = lines.filter( + (l) => + l.getAttribute('y1') === l.getAttribute('y2') && + l.getAttribute('x1') !== l.getAttribute('x2') + ); + expect(horizontalBars.length).toBeGreaterThanOrEqual(1); + }); + + it('positions a single child at the midpoint of its two parents (vertical drop)', async () => { + const PARENT_A = '00000000-0000-0000-0000-00000000000a'; + const PARENT_B = '00000000-0000-0000-0000-00000000000b'; + const CHILD = '00000000-0000-0000-0000-00000000000c'; + render(StammbaumTree, { + nodes: [ + { id: PARENT_A, displayName: 'Walter', familyMember: true }, + { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, + { id: CHILD, displayName: 'Hans', familyMember: true } + ], + edges: [ + { + id: 'sp', + personId: PARENT_A, + relatedPersonId: PARENT_B, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Eugenie', + relationType: 'SPOUSE_OF' + }, + { + id: 'p1', + personId: PARENT_A, + relatedPersonId: CHILD, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + }, + { + id: 'p2', + personId: PARENT_B, + relatedPersonId: CHILD, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const lines = Array.from(document.querySelectorAll('svg line')); + // No slanted segments. With one child, no horizontal sibling bar + // is needed because midX == child.center.x. + const slanted = lines.filter( + (l) => + l.getAttribute('x1') !== l.getAttribute('x2') && + l.getAttribute('y1') !== l.getAttribute('y2') + ); + expect(slanted).toHaveLength(0); + }); + + it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => { + // Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1). + // Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have + // child Lili. Hilde must sit next to Hans, and Lili must be on a + // row below Hans/Hilde — not on the same row. + const WALTER = '00000000-0000-0000-0000-000000000001'; + const EUGENIE = '00000000-0000-0000-0000-000000000002'; + const HANS = '00000000-0000-0000-0000-000000000003'; + const CLARA = '00000000-0000-0000-0000-000000000004'; + const HILDE = '00000000-0000-0000-0000-000000000005'; + const LILI = '00000000-0000-0000-0000-000000000006'; + + render(StammbaumTree, { + nodes: [ + { id: WALTER, displayName: 'Walter', familyMember: true }, + { id: EUGENIE, displayName: 'Eugenie', familyMember: true }, + { id: HANS, displayName: 'Hans', familyMember: true }, + { id: CLARA, displayName: 'Clara', familyMember: true }, + { id: HILDE, displayName: 'Hilde', familyMember: true }, + { id: LILI, displayName: 'Lili', familyMember: true } + ], + edges: [ + { + id: 's1', + personId: WALTER, + relatedPersonId: EUGENIE, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Eugenie', + relationType: 'SPOUSE_OF' + }, + { + id: 'p1', + personId: WALTER, + relatedPersonId: HANS, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + }, + { + id: 'p2', + personId: EUGENIE, + relatedPersonId: HANS, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + }, + { + id: 'p3', + personId: WALTER, + relatedPersonId: CLARA, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Clara', + relationType: 'PARENT_OF' + }, + { + id: 'p4', + personId: EUGENIE, + relatedPersonId: CLARA, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Clara', + relationType: 'PARENT_OF' + }, + { + id: 's2', + personId: HANS, + relatedPersonId: HILDE, + personDisplayName: 'Hans', + relatedPersonDisplayName: 'Hilde', + relationType: 'SPOUSE_OF' + }, + { + id: 'p5', + personId: HANS, + relatedPersonId: LILI, + personDisplayName: 'Hans', + relatedPersonDisplayName: 'Lili', + relationType: 'PARENT_OF' + }, + { + id: 'p6', + personId: HILDE, + relatedPersonId: LILI, + personDisplayName: 'Hilde', + relatedPersonDisplayName: 'Lili', + relationType: 'PARENT_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const ys = new Map(); + for (const g of Array.from(document.querySelectorAll('g[transform]'))) { + const aria = g.getAttribute('aria-label') ?? ''; + const transform = g.getAttribute('transform') ?? ''; + const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); + if (!match) continue; + ys.set(aria.split(',')[0], parseFloat(match[2])); + } + + // Lili must be on a deeper row than Hans / Hilde. + expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!); + expect(ys.get('Hans')).toEqual(ys.get('Hilde')); + + // Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP). + const xs = new Map(); + for (const g of Array.from(document.querySelectorAll('g[transform]'))) { + const aria = g.getAttribute('aria-label') ?? ''; + const transform = g.getAttribute('transform') ?? ''; + const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); + if (!match) continue; + xs.set(aria.split(',')[0], parseFloat(match[1])); + } + expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40); + + // All parent-child segments must be orthogonal. + const lines = Array.from(document.querySelectorAll('svg line')); + const slanted = lines.filter( + (l) => + l.getAttribute('x1') !== l.getAttribute('x2') && + l.getAttribute('y1') !== l.getAttribute('y2') + ); + expect(slanted).toHaveLength(0); + }); + + it('centers two spouse nodes within the minimum viewBox', async () => { + render(StammbaumTree, { + nodes: [ + { id: ID_A, displayName: 'Anna', familyMember: true }, + { id: ID_B, displayName: 'Bertha', familyMember: true } + ], + edges: [ + { + id: 'e1', + personId: ID_A, + relatedPersonId: ID_B, + personDisplayName: 'Anna', + relatedPersonDisplayName: 'Bertha', + relationType: 'SPOUSE_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const svg = document.querySelector('svg')!; + const [x, y, w, h] = parseViewBox(svg); + + expect(w).toBe(1200); + expect(h).toBe(800); + + const c = rectsCentroid(svg); + expect(x + w / 2).toBeCloseTo(c.x, 1); + expect(y + h / 2).toBeCloseTo(c.y, 1); + }); +}); diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 6acdefbe..e57f212e 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -38,6 +38,9 @@ export type ErrorCode = | 'TAG_NOT_FOUND' | 'TAG_MERGE_SELF' | 'TAG_MERGE_INVALID_TARGET' + | 'RELATIONSHIP_NOT_FOUND' + | 'CIRCULAR_RELATIONSHIP' + | 'DUPLICATE_RELATIONSHIP' | 'MISSING_CREDENTIALS' | 'UNAUTHORIZED' | 'FORBIDDEN' @@ -136,6 +139,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_tag_merge_self(); case 'TAG_MERGE_INVALID_TARGET': return m.error_tag_merge_invalid_target(); + case 'RELATIONSHIP_NOT_FOUND': + return m.error_relationship_not_found(); + case 'CIRCULAR_RELATIONSHIP': + return m.error_circular_relationship(); + case 'DUPLICATE_RELATIONSHIP': + return m.error_duplicate_relationship(); case 'MISSING_CREDENTIALS': return m.login_error_missing_credentials(); case 'UNAUTHORIZED': 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/lib/relationshipLabels.test.ts b/frontend/src/lib/relationshipLabels.test.ts new file mode 100644 index 00000000..a62033a7 --- /dev/null +++ b/frontend/src/lib/relationshipLabels.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { m } from '$lib/paraglide/messages.js'; +import { chipLabel, otherName } from './relationshipLabels'; +import type { components } from '$lib/generated/api'; + +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +const ALICE_ID = 'alice-uuid'; +const BOB_ID = 'bob-uuid'; + +function makeRel( + relationType: RelationshipDTO['relationType'], + override: Partial = {} +): RelationshipDTO { + return { + id: 'rel-1', + personId: ALICE_ID, + relatedPersonId: BOB_ID, + personDisplayName: 'Alice', + relatedPersonDisplayName: 'Bob', + relationType, + ...override + }; +} + +describe('chipLabel', () => { + it('returns parent_of when perspective is the subject of PARENT_OF', () => { + const rel = makeRel('PARENT_OF'); + expect(chipLabel(rel, ALICE_ID)).toBe(m.relation_parent_of()); + }); + + it('returns child_of when perspective is the object of PARENT_OF', () => { + const rel = makeRel('PARENT_OF'); + expect(chipLabel(rel, BOB_ID)).toBe(m.relation_child_of()); + }); + + it('returns spouse_of for SPOUSE_OF regardless of perspective', () => { + const rel = makeRel('SPOUSE_OF'); + expect(chipLabel(rel, ALICE_ID)).toBe(m.relation_spouse_of()); + expect(chipLabel(rel, BOB_ID)).toBe(m.relation_spouse_of()); + }); + + it('returns sibling_of for SIBLING_OF', () => { + expect(chipLabel(makeRel('SIBLING_OF'), ALICE_ID)).toBe(m.relation_sibling_of()); + }); + + it('returns friend for FRIEND', () => { + expect(chipLabel(makeRel('FRIEND'), ALICE_ID)).toBe(m.relation_friend()); + }); + + it('returns other for OTHER', () => { + expect(chipLabel(makeRel('OTHER'), ALICE_ID)).toBe(m.relation_other()); + }); +}); + +describe('otherName', () => { + it('returns relatedPersonDisplayName when perspective is the subject', () => { + const rel = makeRel('PARENT_OF'); + expect(otherName(rel, ALICE_ID)).toBe('Bob'); + }); + + it('returns personDisplayName when perspective is the object', () => { + const rel = makeRel('PARENT_OF'); + expect(otherName(rel, BOB_ID)).toBe('Alice'); + }); +}); diff --git a/frontend/src/lib/relationshipLabels.ts b/frontend/src/lib/relationshipLabels.ts new file mode 100644 index 00000000..26416289 --- /dev/null +++ b/frontend/src/lib/relationshipLabels.ts @@ -0,0 +1,77 @@ +import * as m from '$lib/paraglide/messages.js'; +import type { components } from '$lib/generated/api'; + +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +export function chipLabel(rel: RelationshipDTO, perspectivePersonId: string): string { + const viewpointIsSubject = rel.personId === perspectivePersonId; + switch (rel.relationType) { + case 'PARENT_OF': + return viewpointIsSubject ? m.relation_parent_of() : m.relation_child_of(); + case 'SPOUSE_OF': + return m.relation_spouse_of(); + case 'SIBLING_OF': + return m.relation_sibling_of(); + case 'FRIEND': + return m.relation_friend(); + case 'COLLEAGUE': + return m.relation_colleague(); + case 'EMPLOYER': + return m.relation_employer(); + case 'DOCTOR': + return m.relation_doctor(); + case 'NEIGHBOR': + return m.relation_neighbor(); + default: + return m.relation_other(); + } +} + +export function otherName(rel: RelationshipDTO, perspectivePersonId: string): string { + return rel.personId === perspectivePersonId + ? rel.relatedPersonDisplayName + : rel.personDisplayName; +} + +/** + * Maps a backend inferred-label key (parent, uncle_aunt, ...) to its + * localised string. Unknown keys fall back to "distant". + */ +export function inferredRelationshipLabel(key: string): string { + switch (key) { + case 'parent': + return m.relation_inferred_parent(); + case 'child': + return m.relation_inferred_child(); + case 'spouse': + return m.relation_inferred_spouse(); + case 'sibling': + return m.relation_inferred_sibling(); + case 'grandparent': + return m.relation_inferred_grandparent(); + case 'grandchild': + return m.relation_inferred_grandchild(); + case 'great_grandparent': + return m.relation_inferred_great_grandparent(); + case 'great_grandchild': + return m.relation_inferred_great_grandchild(); + case 'uncle_aunt': + return m.relation_inferred_uncle_aunt(); + case 'niece_nephew': + return m.relation_inferred_niece_nephew(); + case 'great_uncle_aunt': + return m.relation_inferred_great_uncle_aunt(); + case 'great_niece_nephew': + return m.relation_inferred_great_niece_nephew(); + case 'inlaw_parent': + return m.relation_inferred_inlaw_parent(); + case 'inlaw_child': + return m.relation_inferred_inlaw_child(); + case 'sibling_inlaw': + return m.relation_inferred_sibling_inlaw(); + case 'cousin_1': + return m.relation_inferred_cousin_1(); + default: + return m.relation_inferred_distant(); + } +} diff --git a/frontend/src/routes/AppNav.svelte b/frontend/src/routes/AppNav.svelte index 5c81538f..e0c27f51 100644 --- a/frontend/src/routes/AppNav.svelte +++ b/frontend/src/routes/AppNav.svelte @@ -60,13 +60,13 @@ function handleOverlayKeydown(event: KeyboardEvent) { - {m.nav_conversations()} + {m.nav_stammbaum()} {#if isAdmin} - {m.nav_conversations()} + {m.nav_stammbaum()} {#if isAdmin} 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 }); diff --git a/frontend/src/routes/documents/[id]/+page.server.ts b/frontend/src/routes/documents/[id]/+page.server.ts index 5fb27114..e601dd44 100644 --- a/frontend/src/routes/documents/[id]/+page.server.ts +++ b/frontend/src/routes/documents/[id]/+page.server.ts @@ -1,6 +1,7 @@ import { error, redirect } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; +import { inferredRelationshipLabel } from '$lib/relationshipLabels'; export async function load({ params, fetch }) { const { id } = params; @@ -15,5 +16,38 @@ export async function load({ params, fetch }) { throw error(docResult.response.status, getErrorMessage(code)); } - return { document: docResult.data! }; + const document = docResult.data!; + const inferredRelationship = await loadInferredRelationship(api, document); + + return { document, inferredRelationship }; +} + +async function loadInferredRelationship( + api: ReturnType, + document: { + sender?: { id: string; familyMember?: boolean } | null; + receivers?: { id: string; familyMember?: boolean }[]; + } +): Promise<{ labelFromA: string; labelFromB: string } | null> { + const sender = document.sender; + const receivers = document.receivers ?? []; + + // The badge is shown only when both endpoints are family members and the + // document has exactly one receiver. + if (!sender?.familyMember) return null; + if (receivers.length !== 1) return null; + const receiver = receivers[0]; + if (!receiver?.familyMember) return null; + + const result = await api.GET('/api/persons/{aId}/relationship-to/{bId}', { + params: { path: { aId: sender.id, bId: receiver.id } } + }); + + if (result.response.status === 404) return null; + if (!result.response.ok || !result.data) return null; + + return { + labelFromA: inferredRelationshipLabel(result.data.labelFromA), + labelFromB: inferredRelationshipLabel(result.data.labelFromB) + }; } diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 9c52faa6..5c780a54 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -395,6 +395,7 @@ onMount(() => { canWrite={canWrite} fileUrl={fileLoader.fileUrl} bind:transcribeMode={transcribeMode} + inferredRelationship={data.inferredRelationship} />
    diff --git a/frontend/src/routes/persons/[id]/+page.server.ts b/frontend/src/routes/persons/[id]/+page.server.ts index 3c5f0f5c..33b3ae74 100644 --- a/frontend/src/routes/persons/[id]/+page.server.ts +++ b/frontend/src/routes/persons/[id]/+page.server.ts @@ -11,11 +11,20 @@ export async function load({ params, fetch, locals }) { g.permissions.includes('WRITE_ALL') ) ?? false; - const [personResult, sentDocsResult, receivedDocsResult, aliasesResult] = await Promise.all([ + const [ + personResult, + sentDocsResult, + receivedDocsResult, + aliasesResult, + relsResult, + inferredResult + ] = await Promise.all([ api.GET('/api/persons/{id}', { params: { path: { id } } }), api.GET('/api/persons/{id}/documents', { params: { path: { id } } }), api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }), - api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }) + api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }), + api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }), + api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } }) ]); if (!personResult.response.ok) { @@ -28,6 +37,8 @@ export async function load({ params, fetch, locals }) { sentDocuments: sentDocsResult.data ?? [], receivedDocuments: receivedDocsResult.data ?? [], aliases: aliasesResult.data ?? [], + relationships: relsResult.data ?? [], + inferredRelationships: inferredResult.data ?? [], canWrite }; } diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 64978be9..58c398b4 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -6,6 +6,7 @@ import PersonCard from './PersonCard.svelte'; import NameHistoryCard from './NameHistoryCard.svelte'; import CoCorrespondentsList from './CoCorrespondentsList.svelte'; import PersonDocumentList from './PersonDocumentList.svelte'; +import PersonRelationshipsCard from './PersonRelationshipsCard.svelte'; let { data } = $props(); @@ -64,21 +65,33 @@ const coCorrespondents = $derived.by(() => {
    - +
    - +
    + +
    - +
    + +
    + +
    + +
    diff --git a/frontend/src/routes/persons/[id]/PersonMergePanel.svelte b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte index 50d10d8d..b032a15d 100644 --- a/frontend/src/routes/persons/[id]/PersonMergePanel.svelte +++ b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte @@ -15,7 +15,7 @@ let mergeTargetId = $state(''); let showMergeConfirm = $state(false); -
    +

    {m.person_merge_heading()}

    diff --git a/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte b/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte new file mode 100644 index 00000000..96ca77a1 --- /dev/null +++ b/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte @@ -0,0 +1,75 @@ + + +

    +

    + {m.person_relationships_heading()} +

    + + {#if relationships.length === 0 && topDerived.length === 0} +

    {m.person_relationships_empty()}

    + {:else} + {#if relationships.length > 0} + + {/if} + + {#if topDerived.length > 0} + + {/if} + {/if} +
    diff --git a/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte.test.ts b/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte.test.ts new file mode 100644 index 00000000..ebdd2f8a --- /dev/null +++ b/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonRelationshipsCard from './PersonRelationshipsCard.svelte'; + +const PERSON_ID = '00000000-0000-0000-0000-000000000001'; +const SPOUSE_ID = '00000000-0000-0000-0000-000000000002'; +const PARENT_ID = '00000000-0000-0000-0000-000000000003'; + +describe('PersonRelationshipsCard', () => { + it('hides an inferred relationship that is already a direct one', async () => { + render(PersonRelationshipsCard, { + personId: PERSON_ID, + relationships: [ + { + id: 'r1', + personId: PERSON_ID, + relatedPersonId: SPOUSE_ID, + personDisplayName: 'Anna Müller', + relatedPersonDisplayName: 'Bertha Müller', + relationType: 'SPOUSE_OF' + } + ], + inferredRelationships: [ + { + person: { + id: SPOUSE_ID, + displayName: 'Bertha Müller', + familyMember: true + }, + label: 'SPOUSE', + hops: 1 + } + ] + }); + + const matches = await page.getByText('Bertha Müller').all(); + expect(matches).toHaveLength(1); + }); + + it('still renders inferred relationships that are not direct', async () => { + const COUSIN_ID = '00000000-0000-0000-0000-000000000003'; + render(PersonRelationshipsCard, { + personId: PERSON_ID, + relationships: [], + inferredRelationships: [ + { + person: { id: COUSIN_ID, displayName: 'Carla Cousine', familyMember: true }, + label: 'COUSIN', + hops: 4 + } + ] + }); + + await expect.element(page.getByText('Carla Cousine')).toBeInTheDocument(); + }); + + it('shows Elternteil-von chip when personId is the PARENT_OF subject', async () => { + render(PersonRelationshipsCard, { + personId: PERSON_ID, + relationships: [ + { + id: 'r1', + personId: PERSON_ID, + relatedPersonId: PARENT_ID, + personDisplayName: 'Anna Müller', + relatedPersonDisplayName: 'Kind Müller', + relationType: 'PARENT_OF' + } + ], + inferredRelationships: [] + }); + + await expect.element(page.getByText('Elternteil von')).toBeInTheDocument(); + }); + + it('chip labels use text-xs (≥12px) not text-[10px] — WCAG readable font size', async () => { + render(PersonRelationshipsCard, { + personId: PERSON_ID, + relationships: [ + { + id: 'r1', + personId: PERSON_ID, + relatedPersonId: SPOUSE_ID, + personDisplayName: 'Anna', + relatedPersonDisplayName: 'Bertha', + relationType: 'SPOUSE_OF' + } + ], + inferredRelationships: [ + { + person: { id: PARENT_ID, displayName: 'Großmutter', familyMember: true }, + label: 'Großmutter', + hops: 2 + } + ] + }); + const chips = document.querySelectorAll('li span.rounded-full'); + expect(chips.length).toBeGreaterThan(0); + chips.forEach((chip) => { + expect(chip.classList.contains('text-xs')).toBe(true); + expect(chip.classList.contains('text-[10px]')).toBe(false); + }); + }); + + it('shows Kind-von chip when personId is the PARENT_OF object', async () => { + render(PersonRelationshipsCard, { + personId: PERSON_ID, + relationships: [ + { + id: 'r2', + personId: PARENT_ID, + relatedPersonId: PERSON_ID, + personDisplayName: 'Eltern Müller', + relatedPersonDisplayName: 'Anna Müller', + relationType: 'PARENT_OF' + } + ], + inferredRelationships: [] + }); + + await expect.element(page.getByText('Kind von')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts index c3901f8f..9658e59f 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.server.ts +++ b/frontend/src/routes/persons/[id]/edit/+page.server.ts @@ -17,9 +17,11 @@ export async function load({ params, fetch, locals }) { const { id } = params; const api = createApiClient(fetch); - const [result, aliasesResult] = await Promise.all([ + const [result, aliasesResult, relsResult, inferredResult] = await Promise.all([ api.GET('/api/persons/{id}', { params: { path: { id } } }), - api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }) + api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }), + api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }), + api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } }) ]); if (!result.response.ok) { @@ -29,7 +31,12 @@ export async function load({ params, fetch, locals }) { const person = result.data!; const personType = normalizePersonType(person.personType); - return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] }; + return { + person: { ...person, personType }, + aliases: aliasesResult.data ?? [], + relationships: relsResult.data ?? [], + inferredRelationships: inferredResult.data ?? [] + }; } export const actions = { @@ -146,5 +153,86 @@ export const actions = { } return { aliasSuccess: true }; + }, + + toggleFamilyMember: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const value = formData.get('familyMember')?.toString() === 'true'; + + const api = createApiClient(fetch); + const result = await api.PATCH('/api/persons/{id}/family-member', { + params: { path: { id: params.id } }, + body: { familyMember: value } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { relationshipError: getErrorMessage(code) }); + } + return { relationshipSuccess: true }; + }, + + addRelationship: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const relatedPersonId = formData.get('relatedPersonId')?.toString(); + const relationType = formData.get('relationType')?.toString(); + const fromYearRaw = formData.get('fromYear')?.toString().trim(); + const toYearRaw = formData.get('toYear')?.toString().trim(); + const notes = formData.get('notes')?.toString().trim() || undefined; + + if (!relatedPersonId || !relationType) { + return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); + } + if (relatedPersonId === params.id) { + return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); + } + const fromYear = fromYearRaw ? parseInt(fromYearRaw, 10) : undefined; + const toYear = toYearRaw ? parseInt(toYearRaw, 10) : undefined; + if ( + fromYear !== undefined && + toYear !== undefined && + !Number.isNaN(fromYear) && + !Number.isNaN(toYear) && + toYear < fromYear + ) { + return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); + } + + const api = createApiClient(fetch); + const result = await api.POST('/api/persons/{id}/relationships', { + params: { path: { id: params.id } }, + body: { + relatedPersonId, + relationType, + ...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}), + ...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}), + ...(notes ? { notes } : {}) + } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { relationshipError: getErrorMessage(code) }); + } + return { relationshipSuccess: true }; + }, + + deleteRelationship: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const relId = formData.get('relId')?.toString(); + if (!relId) { + return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); + } + + const api = createApiClient(fetch); + const result = await api.DELETE('/api/persons/{id}/relationships/{relId}', { + params: { path: { id: params.id, relId } } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { relationshipError: getErrorMessage(code) }); + } + return { relationshipSuccess: true }; } }; diff --git a/frontend/src/routes/persons/[id]/edit/+page.svelte b/frontend/src/routes/persons/[id]/edit/+page.svelte index b49dc471..f7dd05ce 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.svelte +++ b/frontend/src/routes/persons/[id]/edit/+page.svelte @@ -6,6 +6,7 @@ import PersonEditForm from './PersonEditForm.svelte'; import PersonEditSaveBar from './PersonEditSaveBar.svelte'; import NameHistoryEditCard from './NameHistoryEditCard.svelte'; import PersonMergePanel from '../PersonMergePanel.svelte'; +import StammbaumCard from '$lib/components/StammbaumCard.svelte'; let { data, form } = $props(); const person = $derived(data.person); @@ -35,6 +36,15 @@ const person = $derived(data.person); + + {#key person.id} {/key} diff --git a/frontend/src/routes/persons/[id]/page.server.spec.ts b/frontend/src/routes/persons/[id]/page.server.spec.ts index c41ef58b..fcf24eb7 100644 --- a/frontend/src/routes/persons/[id]/page.server.spec.ts +++ b/frontend/src/routes/persons/[id]/page.server.spec.ts @@ -27,6 +27,8 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1', title: 'Brief' }] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); @@ -47,6 +49,8 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter }); @@ -65,6 +69,8 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: false }, data: null }) .mockResolvedValueOnce({ response: { ok: false }, data: null }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); @@ -85,6 +91,8 @@ describe('person detail load — error paths', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); await expect( @@ -102,6 +110,8 @@ describe('person detail load — error paths', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); await expect( diff --git a/frontend/src/routes/stammbaum/+page.server.ts b/frontend/src/routes/stammbaum/+page.server.ts new file mode 100644 index 00000000..b35c75d5 --- /dev/null +++ b/frontend/src/routes/stammbaum/+page.server.ts @@ -0,0 +1,18 @@ +import { error, redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export async function load({ fetch }) { + const api = createApiClient(fetch); + const result = await api.GET('/api/network'); + + if (result.response.status === 401) throw redirect(302, '/login'); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); + } + + const network = result.data!; + return { nodes: network.nodes ?? [], edges: network.edges ?? [] }; +} diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte new file mode 100644 index 00000000..f2e4423b --- /dev/null +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -0,0 +1,128 @@ + + + +
    +
    +

    {m.nav_stammbaum()}

    + {#if data.nodes.length > 0} +
    + + +
    + {/if} +
    + + {#if data.nodes.length === 0} +
    +
    + +

    {m.stammbaum_empty_heading()}

    +

    {m.stammbaum_empty_body()}

    + + {m.stammbaum_empty_link()} + +
    +
    + {:else} +
    +
    + (selectedId = id)} + /> +
    + {#if selectedNode} + + + +
    + (selectedId = null)} + /> +
    + {/if} +
    + {/if} +