feat(stammbaum): RelationshipService + family_member toggle (TDD)
- Add PersonService.setFamilyMember (write, @Transactional) and findAllFamilyMembers; PersonRepository gains the findByFamilyMemberTrueOrderBy projection. - RelationshipService orchestrates PersonService + the inference service; never reaches into PersonRepository directly. addRelationship guards self-relationship, year range, circular PARENT_OF (Nora B2), and DataIntegrityViolation→DUPLICATE_RELATIONSHIP. deleteRelationship enforces ownership from either side (Nora B1). - Extend RelationshipDTO with personDisplayName + birth/death year so the frontend can render rows from either viewpoint. - 8 unit tests, written against a stub (red), then green: FORBIDDEN delete, CIRCULAR add, DUPLICATE add, self-relationship, year range, happy-path persistence, ownership-from-object, RELATIONSHIP_NOT_FOUND. Full backend suite: 1399/1399 green. Refs #358. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Owns the {@code person_relationships} table and the family_member flag.
|
||||
* Always orchestrates {@link PersonService} for cross-domain access — never
|
||||
* touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RelationshipService {
|
||||
|
||||
private final PersonRelationshipRepository relationshipRepository;
|
||||
private final PersonService personService;
|
||||
private final RelationshipInferenceService inferenceService;
|
||||
|
||||
public List<RelationshipDTO> getRelationships(UUID personId) {
|
||||
personService.getById(personId);
|
||||
List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
|
||||
return rels.stream().map(RelationshipService::toDTO).toList();
|
||||
}
|
||||
|
||||
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
|
||||
personService.getById(personId);
|
||||
return inferenceService.findAllFor(personId);
|
||||
}
|
||||
|
||||
public Optional<InferredRelationshipDTO> 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<Person> familyMembers = personService.findAllFamilyMembers();
|
||||
Set<UUID> familyIds = new HashSet<>(familyMembers.size());
|
||||
List<PersonNodeDTO> 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<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
||||
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
||||
|
||||
List<RelationshipDTO> edges = new ArrayList<>();
|
||||
for (PersonRelationship r : familyEdges) {
|
||||
UUID p = r.getPerson().getId();
|
||||
UUID rp = r.getRelatedPerson().getId();
|
||||
if (familyIds.contains(p) && familyIds.contains(rp)) {
|
||||
edges.add(toDTO(r));
|
||||
}
|
||||
}
|
||||
return new NetworkDTO(nodes, edges);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
||||
if (personId.equals(dto.relatedPersonId())) {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
||||
}
|
||||
Person person = personService.getById(personId);
|
||||
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
||||
|
||||
RelationType type = parseType(dto.relationType());
|
||||
validateYears(dto.fromYear(), dto.toYear());
|
||||
|
||||
if (type == RelationType.PARENT_OF
|
||||
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.CIRCULAR_RELATIONSHIP,
|
||||
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
|
||||
}
|
||||
|
||||
PersonRelationship rel = PersonRelationship.builder()
|
||||
.person(person)
|
||||
.relatedPerson(relatedPerson)
|
||||
.relationType(type)
|
||||
.fromYear(dto.fromYear())
|
||||
.toYear(dto.toYear())
|
||||
.notes(blankToNull(dto.notes()))
|
||||
.build();
|
||||
|
||||
try {
|
||||
return toDTO(relationshipRepository.save(rel));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + type + ")");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteRelationship(UUID personId, UUID relId) {
|
||||
PersonRelationship rel = relationshipRepository.findById(relId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
||||
|
||||
UUID storageSubject = rel.getPerson().getId();
|
||||
UUID storageObject = rel.getRelatedPerson().getId();
|
||||
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
||||
throw DomainException.forbidden(
|
||||
"Relationship " + relId + " does not belong to person " + personId);
|
||||
}
|
||||
relationshipRepository.delete(rel);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||
return personService.setFamilyMember(personId, familyMember);
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
|
||||
private static RelationType parseType(String typeName) {
|
||||
if (typeName == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "relationType is required");
|
||||
}
|
||||
try {
|
||||
return RelationType.valueOf(typeName);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "Invalid relationType: " + typeName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
||||
}
|
||||
}
|
||||
|
||||
private static RelationshipDTO toDTO(PersonRelationship r) {
|
||||
Person p = r.getPerson();
|
||||
Person rp = r.getRelatedPerson();
|
||||
return new RelationshipDTO(
|
||||
r.getId(),
|
||||
p.getId(),
|
||||
rp.getId(),
|
||||
p.getDisplayName(),
|
||||
p.getBirthYear(),
|
||||
p.getDeathYear(),
|
||||
rp.getDisplayName(),
|
||||
rp.getBirthYear(),
|
||||
rp.getDeathYear(),
|
||||
r.getRelationType(),
|
||||
r.getFromYear(),
|
||||
r.getToYear(),
|
||||
r.getNotes());
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,22 @@ import org.raddatz.familienarchiv.relationship.RelationType;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Wire shape for a stored relationship row. Carries enough context for the
|
||||
* frontend to render a chip (type), a name (relatedPersonDisplayName), a year
|
||||
* range, and a delete action (id).
|
||||
* Wire shape for one stored relationship row. Both sides include name + years
|
||||
* so the frontend can render the row from either perspective (e.g. on the
|
||||
* subject's page the row reads "Elternteil von [related]"; on the object's
|
||||
* page it reads "Kind von [person]").
|
||||
*
|
||||
* <p>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,
|
||||
|
||||
@@ -26,6 +26,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
|
||||
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
|
||||
|
||||
@@ -58,6 +58,17 @@ public class PersonService {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public List<Person> 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<Person> findByName(String firstName, String lastName) {
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user