feat(stammbaum): family network — graph, badge, edit card, /stammbaum page (#358) #360

Merged
marcel merged 57 commits from feat/stammbaum-issue-358 into main 2026-04-28 19:33:33 +02:00
2 changed files with 96 additions and 0 deletions
Showing only changes of commit f29f4d3f5b - Show all commits

View File

@@ -0,0 +1,92 @@
package org.raddatz.familienarchiv.relationship;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.UUID;
/**
* Stammbaum API. Endpoints split across two roots:
* <ul>
* <li>{@code /api/network} — the family graph</li>
* <li>{@code /api/persons/{id}/...} — per-person relationship operations
* (PersonController is intentionally left untouched)</li>
* </ul>
*/
@RestController
@RequiredArgsConstructor
public class RelationshipController {
private final RelationshipService relationshipService;
@GetMapping("/api/network")
public NetworkDTO getNetwork() {
return relationshipService.getFamilyNetwork();
}
@GetMapping("/api/persons/{id}/relationships")
public List<RelationshipDTO> getRelationships(@PathVariable UUID id) {
return relationshipService.getRelationships(id);
}
@GetMapping("/api/persons/{id}/inferred-relationships")
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(@PathVariable UUID id) {
return relationshipService.getInferredRelationships(id);
}
@GetMapping("/api/persons/{aId}/relationship-to/{bId}")
public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) {
return relationshipService.getRelationshipBetween(aId, bId)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "No relationship path between " + aId + " and " + bId));
}
@PostMapping("/api/persons/{id}/relationships")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<RelationshipDTO> addRelationship(
@PathVariable UUID id,
@Valid @RequestBody CreateRelationshipRequest dto) {
validateRelationType(dto.relationType());
return ResponseEntity.status(HttpStatus.CREATED)
.body(relationshipService.addRelationship(id, dto));
}
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) {
relationshipService.deleteRelationship(id, relId);
}
@PatchMapping("/api/persons/{id}/family-member")
@RequirePermission(Permission.WRITE_ALL)
public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) {
return relationshipService.setFamilyMember(id, dto.familyMember());
}
private static void validateRelationType(String typeName) {
if (typeName == null || typeName.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "relationType is required");
}
try {
RelationType.valueOf(typeName);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unknown relationType: " + typeName);
}
}
}

View File

@@ -0,0 +1,4 @@
package org.raddatz.familienarchiv.relationship.dto;
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
public record FamilyMemberPatchDTO(boolean familyMember) {}