From 22fe9600a14d38e8264b45e3a8704b6d21d3862b Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:02:51 +0200 Subject: [PATCH 01/20] feat(migration): V21 add person_name_aliases table with pg_trgm indexes Creates the alias table for historical name changes (marriage, widowhood, etc.) and adds GIN trigram indexes on both the new alias table and the existing persons table for substring search. Co-Authored-By: Claude Sonnet 4.6 --- .../V21__add_person_name_aliases.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V21__add_person_name_aliases.sql diff --git a/backend/src/main/resources/db/migration/V21__add_person_name_aliases.sql b/backend/src/main/resources/db/migration/V21__add_person_name_aliases.sql new file mode 100644 index 00000000..1c7e706e --- /dev/null +++ b/backend/src/main/resources/db/migration/V21__add_person_name_aliases.sql @@ -0,0 +1,22 @@ +-- Enable pg_trgm for substring search via GIN indexes +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Historical name aliases for persons (marriage, widowhood, etc.) +CREATE TABLE person_name_aliases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + last_name VARCHAR(255) NOT NULL, + first_name VARCHAR(255), + type VARCHAR(50) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Indexes on alias table +CREATE INDEX idx_aliases_person_id ON person_name_aliases(person_id); +CREATE INDEX idx_aliases_last_name_trgm ON person_name_aliases USING GIN (lower(last_name) gin_trgm_ops); + +-- Retroactive GIN trigram indexes on existing persons table for substring search +CREATE INDEX idx_persons_first_name_trgm ON persons USING GIN (lower(first_name) gin_trgm_ops); +CREATE INDEX idx_persons_last_name_trgm ON persons USING GIN (lower(last_name) gin_trgm_ops); +CREATE INDEX idx_persons_alias_trgm ON persons USING GIN (lower(alias) gin_trgm_ops); -- 2.49.1 From 765cbfbaafa9d68b2ed7d97dca603c918268f28d Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:04:38 +0200 Subject: [PATCH 02/20] feat(model): add PersonNameAlias entity, type enum, repository, DTO Introduces the alias domain model: entity with @ManyToOne to Person, @OneToMany on Person for JPA graph navigation, repository with sort_order queries, input DTO, and ALIAS_NOT_FOUND error code. Co-Authored-By: Claude Sonnet 4.6 --- .../dto/PersonNameAliasDTO.java | 9 ++++ .../familienarchiv/exception/ErrorCode.java | 2 + .../raddatz/familienarchiv/model/Person.java | 11 +++++ .../familienarchiv/model/PersonNameAlias.java | 48 +++++++++++++++++++ .../model/PersonNameAliasType.java | 8 ++++ .../repository/PersonNameAliasRepository.java | 16 +++++++ 6 files changed, 94 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/PersonNameAliasRepository.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java new file mode 100644 index 00000000..2ce8a04d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.dto; + +import org.raddatz.familienarchiv.model.PersonNameAliasType; + +public record PersonNameAliasDTO( + String lastName, + String firstName, + PersonNameAliasType type +) {} 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 5952dba5..b105df54 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -11,6 +11,8 @@ public enum ErrorCode { // --- Persons --- /** A person with the given ID does not exist. 404 */ PERSON_NOT_FOUND, + /** A person name alias with the given ID does not exist. 404 */ + ALIAS_NOT_FOUND, // --- Documents --- /** A document with the given ID does not exist. 404 */ 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 ce2595b5..3bd6f418 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java @@ -1,9 +1,12 @@ package org.raddatz.familienarchiv.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.*; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; @Entity @Table(name = "persons") @@ -35,4 +38,12 @@ public class Person { private Integer birthYear; private Integer deathYear; + + // 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. + @OneToMany(mappedBy = "person", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnore + @Builder.Default + private List nameAliases = new ArrayList<>(); } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java new file mode 100644 index 00000000..0b63bead --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java @@ -0,0 +1,48 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "person_name_aliases") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PersonNameAlias { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "person_id", nullable = false) + private Person person; + + @Column(name = "last_name", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String lastName; + + @Column(name = "first_name") + private String firstName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private PersonNameAliasType type; + + @Column(name = "sort_order", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Integer sortOrder; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Instant createdAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java new file mode 100644 index 00000000..38ad90cf --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java @@ -0,0 +1,8 @@ +package org.raddatz.familienarchiv.model; + +public enum PersonNameAliasType { + BIRTH, + WIDOWED, + DIVORCED, + OTHER +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonNameAliasRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonNameAliasRepository.java new file mode 100644 index 00000000..1f97860b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonNameAliasRepository.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.PersonNameAlias; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.UUID; + +public interface PersonNameAliasRepository extends JpaRepository { + + List findByPersonIdOrderBySortOrderAscCreatedAtAsc(UUID personId); + + @Query("SELECT COALESCE(MAX(a.sortOrder), -1) FROM PersonNameAlias a WHERE a.person.id = :personId") + int findMaxSortOrder(UUID personId); +} -- 2.49.1 From 0fc568dd9f18c27d04273c19d35f6c37d89c6841 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:07:14 +0200 Subject: [PATCH 03/20] feat(service): add alias CRUD methods to PersonService getAliases (sorted by sort_order), addAlias (auto-incrementing sort_order), removeAlias (with IDOR protection verifying alias belongs to the given person). All TDD with 7 new unit tests. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/PersonService.java | 35 ++++++ .../service/PersonServiceTest.java | 100 ++++++++++++++++++ 2 files changed, 135 insertions(+) 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 f295715f..51bebc24 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -4,11 +4,14 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonNameAliasDTO; import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonNameAlias; +import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -22,6 +25,7 @@ import lombok.RequiredArgsConstructor; public class PersonService { private final PersonRepository personRepository; + private final PersonNameAliasRepository aliasRepository; public List findAll(String q) { if (q == null) { @@ -137,4 +141,35 @@ public class PersonService { personRepository.deleteById(sourceId); } + + // ─── Alias management ─────────────────────────────────────────────────── + + public List getAliases(UUID personId) { + getById(personId); + return aliasRepository.findByPersonIdOrderBySortOrderAscCreatedAtAsc(personId); + } + + @Transactional + public PersonNameAlias addAlias(UUID personId, PersonNameAliasDTO dto) { + Person person = getById(personId); + int nextSortOrder = aliasRepository.findMaxSortOrder(personId) + 1; + PersonNameAlias alias = PersonNameAlias.builder() + .person(person) + .lastName(dto.lastName()) + .firstName(dto.firstName()) + .type(dto.type()) + .sortOrder(nextSortOrder) + .build(); + return aliasRepository.save(alias); + } + + @Transactional + public void removeAlias(UUID personId, UUID aliasId) { + PersonNameAlias alias = aliasRepository.findById(aliasId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.ALIAS_NOT_FOUND, "Alias not found: " + aliasId)); + if (!alias.getPerson().getId().equals(personId)) { + throw DomainException.forbidden("Alias does not belong to this person"); + } + aliasRepository.delete(alias); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index f229f8b9..33288bf6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -5,10 +5,14 @@ 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.dto.PersonNameAliasDTO; import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonNameAlias; +import org.raddatz.familienarchiv.model.PersonNameAliasType; +import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.web.server.ResponseStatusException; @@ -25,6 +29,7 @@ import static org.mockito.Mockito.*; class PersonServiceTest { @Mock PersonRepository personRepository; + @Mock PersonNameAliasRepository aliasRepository; @InjectMocks PersonService personService; // ─── getById ───────────────────────────────────────────────────────────── @@ -436,4 +441,99 @@ class PersonServiceTest { verify(personRepository).deleteReceiverReferences(sourceId); verify(personRepository).deleteById(sourceId); } + + // ─── getAliases ───────────────────────────────────────────────────────── + + @Test + void getAliases_returnsSortedAliases() { + UUID personId = UUID.randomUUID(); + when(personRepository.findById(personId)).thenReturn(Optional.of( + Person.builder().id(personId).firstName("Clara").lastName("Cram").build())); + List aliases = List.of( + PersonNameAlias.builder().id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + when(aliasRepository.findByPersonIdOrderBySortOrderAscCreatedAtAsc(personId)).thenReturn(aliases); + + List result = personService.getAliases(personId); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getLastName()).isEqualTo("de Gruyter"); + } + + @Test + void getAliases_throwsNotFound_whenPersonMissing() { + UUID personId = UUID.randomUUID(); + when(personRepository.findById(personId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> personService.getAliases(personId)) + .isInstanceOf(DomainException.class); + } + + // ─── addAlias ─────────────────────────────────────────────────────────── + + @Test + void addAlias_savesWithAutoIncrementedSortOrder() { + UUID personId = UUID.randomUUID(); + Person person = Person.builder().id(personId).firstName("Clara").lastName("Cram").build(); + when(personRepository.findById(personId)).thenReturn(Optional.of(person)); + when(aliasRepository.findMaxSortOrder(personId)).thenReturn(2); + when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonNameAliasDTO dto = new PersonNameAliasDTO("de Gruyter", null, PersonNameAliasType.BIRTH); + PersonNameAlias result = personService.addAlias(personId, dto); + + assertThat(result.getSortOrder()).isEqualTo(3); + assertThat(result.getLastName()).isEqualTo("de Gruyter"); + assertThat(result.getPerson()).isEqualTo(person); + } + + @Test + void addAlias_throwsNotFound_whenPersonMissing() { + UUID personId = UUID.randomUUID(); + when(personRepository.findById(personId)).thenReturn(Optional.empty()); + + PersonNameAliasDTO dto = new PersonNameAliasDTO("de Gruyter", null, PersonNameAliasType.BIRTH); + + assertThatThrownBy(() -> personService.addAlias(personId, dto)) + .isInstanceOf(DomainException.class); + } + + // ─── removeAlias ──────────────────────────────────────────────────────── + + @Test + void removeAlias_deletesAlias_whenBelongsToPerson() { + UUID personId = UUID.randomUUID(); + UUID aliasId = UUID.randomUUID(); + Person person = Person.builder().id(personId).firstName("Clara").lastName("Cram").build(); + PersonNameAlias alias = PersonNameAlias.builder().id(aliasId).person(person).lastName("de Gruyter").build(); + when(aliasRepository.findById(aliasId)).thenReturn(Optional.of(alias)); + + personService.removeAlias(personId, aliasId); + + verify(aliasRepository).delete(alias); + } + + @Test + void removeAlias_throwsNotFound_whenAliasMissing() { + UUID personId = UUID.randomUUID(); + UUID aliasId = UUID.randomUUID(); + when(aliasRepository.findById(aliasId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> personService.removeAlias(personId, aliasId)) + .isInstanceOf(DomainException.class); + } + + @Test + void removeAlias_throwsForbidden_whenAliasDoesNotBelongToPerson() { + UUID personId = UUID.randomUUID(); + UUID otherPersonId = UUID.randomUUID(); + UUID aliasId = UUID.randomUUID(); + Person otherPerson = Person.builder().id(otherPersonId).firstName("Other").lastName("Person").build(); + PersonNameAlias alias = PersonNameAlias.builder().id(aliasId).person(otherPerson).lastName("de Gruyter").build(); + when(aliasRepository.findById(aliasId)).thenReturn(Optional.of(alias)); + + assertThatThrownBy(() -> personService.removeAlias(personId, aliasId)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getStatus().value()) + .isEqualTo(403); + } } -- 2.49.1 From a1d63bbc429d2974298053578aae182f8ec848d7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:09:58 +0200 Subject: [PATCH 04/20] feat(api): add GET/POST/DELETE /api/persons/{id}/aliases endpoints GET returns aliases (no permission required), POST requires WRITE_ALL, DELETE requires WRITE_ALL. 5 new controller tests. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/PersonController.java | 22 +++++++ .../controller/PersonControllerTest.java | 65 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java index 59921087..78da866a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -5,10 +5,12 @@ import java.util.Map; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonNameAliasDTO; import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonNameAlias; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.DocumentService; @@ -92,4 +94,24 @@ public class PersonController { } personService.mergePersons(id, UUID.fromString(targetIdStr)); } + + // ─── Alias endpoints ──────────────────────────────────────────────────── + + @GetMapping("/{id}/aliases") + public List getAliases(@PathVariable UUID id) { + return personService.getAliases(id); + } + + @PostMapping("/{id}/aliases") + @RequirePermission(Permission.WRITE_ALL) + public PersonNameAlias addAlias(@PathVariable UUID id, @RequestBody PersonNameAliasDTO dto) { + return personService.addAlias(id, dto); + } + + @DeleteMapping("/{id}/aliases/{aliasId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission(Permission.WRITE_ALL) + public void removeAlias(@PathVariable UUID id, @PathVariable UUID aliasId) { + personService.removeAlias(id, aliasId); + } } 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 bafb209f..cf29b596 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonNameAlias; +import org.raddatz.familienarchiv.model.PersonNameAliasType; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.DocumentService; @@ -25,6 +27,7 @@ import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -393,4 +396,66 @@ class PersonControllerTest { .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isForbidden()); } + + // ─── GET /api/persons/{id}/aliases ──────────────────────────────────────── + + @Test + @WithMockUser + void getAliases_returns200_withList() throws Exception { + UUID personId = UUID.randomUUID(); + PersonNameAlias alias = PersonNameAlias.builder() + .id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build(); + when(personService.getAliases(personId)).thenReturn(List.of(alias)); + + mockMvc.perform(get("/api/persons/{id}/aliases", personId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].lastName").value("de Gruyter")); + } + + // ─── POST /api/persons/{id}/aliases ────────────────────────────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void addAlias_returns200_whenValid() throws Exception { + UUID personId = UUID.randomUUID(); + PersonNameAlias saved = PersonNameAlias.builder() + .id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build(); + when(personService.addAlias(eq(personId), any())).thenReturn(saved); + + mockMvc.perform(post("/api/persons/{id}/aliases", personId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lastName").value("de Gruyter")); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void addAlias_returns403_withoutWritePermission() throws Exception { + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) + .andExpect(status().isForbidden()); + } + + // ─── DELETE /api/persons/{id}/aliases/{aliasId} ────────────────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void removeAlias_returns204_whenValid() throws Exception { + UUID personId = UUID.randomUUID(); + UUID aliasId = UUID.randomUUID(); + + mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId)) + .andExpect(status().isNoContent()); + + verify(personService).removeAlias(personId, aliasId); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void removeAlias_returns403_withoutWritePermission() throws Exception { + mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID())) + .andExpect(status().isForbidden()); + } } -- 2.49.1 From db61d6b77f15e039f4a8da8710d94e09a9aae156 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:12:54 +0200 Subject: [PATCH 05/20] feat(search): extend person search to include alias last names Adds LEFT JOIN to person_name_aliases in both searchByName (JPQL) and searchWithDocumentCount (native SQL). Uses DISTINCT/GROUP BY to prevent duplicate results. 4 new integration tests. Co-Authored-By: Claude Sonnet 4.6 --- .../repository/PersonRepository.java | 9 ++- .../repository/PersonRepositoryTest.java | 57 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) 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 6e2bd033..abbed802 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -15,11 +15,11 @@ import org.springframework.stereotype.Repository; @Repository public interface PersonRepository extends JpaRepository { - // Suche nach String in Vor- ODER Nachnamen, sortiert nach Nachname - @Query("SELECT p FROM Person p WHERE " + + @Query("SELECT DISTINCT p FROM Person p LEFT JOIN p.nameAliases a WHERE " + "LOWER(CONCAT(p.firstName,' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " + "LOWER(CONCAT(p.lastName, ' ', p.firstName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " + - "LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) " + + "LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " + "ORDER BY p.lastName ASC, p.firstName ASC") List searchByName(@Param("query") String query); @@ -51,9 +51,12 @@ public interface PersonRepository extends JpaRepository { (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 + LEFT JOIN person_name_aliases a ON a.person_id = p.id WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%')) OR LOWER(CONCAT(p.last_name,' ',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.first_name, p.last_name, p.alias, p.birth_year, p.death_year, p.notes ORDER BY p.last_name ASC, p.first_name ASC """, nativeQuery = true) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java index 20811c72..b0873d35 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java @@ -6,6 +6,8 @@ import org.raddatz.familienarchiv.config.FlywayConfig; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonNameAlias; +import org.raddatz.familienarchiv.model.PersonNameAliasType; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; @@ -29,6 +31,9 @@ class PersonRepositoryTest { @Autowired private PersonRepository personRepository; + @Autowired + private PersonNameAliasRepository aliasRepository; + @Autowired private DocumentRepository documentRepository; @@ -383,4 +388,56 @@ class PersonRepositoryTest { assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty(); assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty(); } + + // ─── searchByName with aliases ─────────────────────────────────────────── + + @Test + void searchByName_findsByAliasLastName() { + Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); + aliasRepository.save(PersonNameAlias.builder() + .person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + + List results = personRepository.searchByName("de Gruyter"); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getLastName()).isEqualTo("Cram"); + } + + @Test + void searchByName_stillFindsByCurrentLastName_afterAliasAdded() { + Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); + aliasRepository.save(PersonNameAlias.builder() + .person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + + List results = personRepository.searchByName("Cram"); + + assertThat(results).hasSize(1); + } + + @Test + void searchByName_doesNotReturnDuplicates_whenMultipleAliasesMatch() { + Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); + aliasRepository.save(PersonNameAlias.builder() + .person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + aliasRepository.save(PersonNameAlias.builder() + .person(clara).lastName("Gruyter-Cram").type(PersonNameAliasType.OTHER).sortOrder(1).build()); + + List results = personRepository.searchByName("Gruyter"); + + assertThat(results).hasSize(1); + } + + // ─── searchWithDocumentCount with aliases ──────────────────────────────── + + @Test + void searchWithDocumentCount_findsByAliasLastName() { + Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); + aliasRepository.save(PersonNameAlias.builder() + .person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + + List results = personRepository.searchWithDocumentCount("de Gruyter"); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getLastName()).isEqualTo("Cram"); + } } -- 2.49.1 From 90c9ac9357a848089081b5e39906438d6831988e Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:18:31 +0200 Subject: [PATCH 06/20] feat(search): extend document text search to match alias last names Adds sender alias LEFT JOIN and receiver alias EXISTS subquery to DocumentSpecifications.hasText(). Uses entity-graph navigation via Person.nameAliases (@OneToMany) to avoid a separate DB roundtrip while respecting domain boundaries. 2 new integration tests. Co-Authored-By: Claude Sonnet 4.6 --- .../repository/DocumentSpecifications.java | 20 ++++++++++++++ .../DocumentSpecificationsTest.java | 26 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java index b936f16c..ee9550c1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -9,6 +9,7 @@ import java.util.UUID; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonNameAlias; import org.raddatz.familienarchiv.model.Tag; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; @@ -25,6 +26,12 @@ public class DocumentSpecifications { // LEFT JOIN on sender (ManyToOne — no duplicate rows) Join senderJoin = root.join("sender", JoinType.LEFT); + // LEFT JOIN sender → aliases (entity-graph navigation avoids a separate DB + // roundtrip while respecting domain boundaries — the alias table is part of + // the Person aggregate, navigated via @OneToMany, not via a cross-domain + // repository call from DocumentService) + Join senderAliasJoin = senderJoin.join("nameAliases", JoinType.LEFT); + // EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs Subquery receiverSub = query.subquery(Long.class); Root receiverRoot = receiverSub.from(Document.class); @@ -38,6 +45,17 @@ public class DocumentSpecifications { ) ); + // EXISTS subquery for receiver alias name + Subquery receiverAliasSub = query.subquery(Long.class); + Root receiverAliasRoot = receiverAliasSub.from(Document.class); + Join recAliasPersonJoin = receiverAliasRoot.join("receivers"); + Join recAliasJoin = recAliasPersonJoin.join("nameAliases"); + receiverAliasSub.select(cb.literal(1L)) + .where( + cb.equal(receiverAliasRoot.get("id"), root.get("id")), + cb.like(cb.lower(recAliasJoin.get("lastName")), likePattern) + ); + // EXISTS subquery for tag name — avoids duplicate rows for multi-tag docs Subquery tagSub = query.subquery(Long.class); Root tagRoot = tagSub.from(Document.class); @@ -57,7 +75,9 @@ public class DocumentSpecifications { cb.like(cb.lower(root.get("location")), likePattern), cb.like(cb.lower(senderJoin.get("lastName")), likePattern), cb.like(cb.lower(senderJoin.get("firstName")), likePattern), + cb.like(cb.lower(senderAliasJoin.get("lastName")), likePattern), cb.exists(receiverSub), + cb.exists(receiverAliasSub), cb.exists(tagSub) ); }; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java index c2691213..b13b71fe 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java @@ -7,6 +7,8 @@ import org.raddatz.familienarchiv.config.FlywayConfig; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonNameAlias; +import org.raddatz.familienarchiv.model.PersonNameAliasType; import org.raddatz.familienarchiv.model.Tag; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; @@ -28,6 +30,7 @@ class DocumentSpecificationsTest { @Autowired DocumentRepository documentRepository; @Autowired PersonRepository personRepository; + @Autowired PersonNameAliasRepository aliasRepository; @Autowired TagRepository tagRepository; private Person sender; @@ -325,4 +328,27 @@ class DocumentSpecificationsTest { List result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED))); assertThat(result).isEmpty(); } + + // ─── hasText with aliases ──────────────────────────────────────────────── + + @Test + void hasText_findsDocumentBySenderAliasLastName() { + aliasRepository.save(PersonNameAlias.builder() + .person(sender).lastName("von Mueller").type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + + List result = documentRepository.findAll(Specification.where(hasText("von Mueller"))); + + assertThat(result).isNotEmpty(); + assertThat(result).extracting(Document::getTitle).contains("Alter Brief"); + } + + @Test + void hasText_findsDocumentByReceiverAliasLastName() { + aliasRepository.save(PersonNameAlias.builder() + .person(receiver).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + + List result = documentRepository.findAll(Specification.where(hasText("de Gruyter"))); + + assertThat(result).isNotEmpty(); + } } -- 2.49.1 From f396e079a56c2a275de985d6cb655e575a7a8914 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:21:21 +0200 Subject: [PATCH 07/20] feat(i18n): add alias type labels and section strings for de/en/es Adds 16 new keys per language: alias type labels (BIRTH, WIDOWED, DIVORCED, OTHER), section heading, empty state, add form labels, delete confirmation, and ALIAS_NOT_FOUND error code. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 17 ++++++++++++++++- frontend/messages/en.json | 17 ++++++++++++++++- frontend/messages/es.json | 17 ++++++++++++++++- frontend/src/lib/errors.ts | 3 +++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index a7018bdc..58a677b2 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -479,5 +479,20 @@ "scan_collapse": "Scan verkleinern", "transcription_empty_title": "Noch keine Transkription", "transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.", - "transcription_panel_close": "Panel schließen" + "transcription_panel_close": "Panel schließen", + "person_alias_heading": "Namensverlauf", + "person_alias_empty": "Noch keine Namensaenderungen erfasst.", + "person_alias_type_BIRTH": "geborene/r", + "person_alias_type_WIDOWED": "verwitwete/r", + "person_alias_type_DIVORCED": "geschiedene/r", + "person_alias_type_OTHER": "Sonstiger Name", + "person_alias_add_heading": "Name hinzufuegen", + "person_alias_label_type": "Art", + "person_alias_label_last_name": "Nachname", + "person_alias_label_first_name": "Vorname (optional)", + "person_alias_btn_add": "Hinzufuegen", + "person_alias_delete_title": "Alias entfernen?", + "person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.", + "person_alias_btn_delete": "Entfernen", + "error_alias_not_found": "Der Namensalias wurde nicht gefunden." } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0cedda03..31a7d6a6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -479,5 +479,20 @@ "scan_collapse": "Collapse scan", "transcription_empty_title": "No transcription yet", "transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.", - "transcription_panel_close": "Close panel" + "transcription_panel_close": "Close panel", + "person_alias_heading": "Name history", + "person_alias_empty": "No name changes recorded yet.", + "person_alias_type_BIRTH": "Birth name", + "person_alias_type_WIDOWED": "Name as widow/widower", + "person_alias_type_DIVORCED": "Name after divorce", + "person_alias_type_OTHER": "Other name", + "person_alias_add_heading": "Add name", + "person_alias_label_type": "Type", + "person_alias_label_last_name": "Last name", + "person_alias_label_first_name": "First name (optional)", + "person_alias_btn_add": "Add", + "person_alias_delete_title": "Remove alias?", + "person_alias_delete_body": "This name will be removed from search results.", + "person_alias_btn_delete": "Remove", + "error_alias_not_found": "The name alias was not found." } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3c16f293..5beeb2f2 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -479,5 +479,20 @@ "scan_collapse": "Reducir escaneo", "transcription_empty_title": "Sin transcripcion", "transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.", - "transcription_panel_close": "Cerrar panel" + "transcription_panel_close": "Cerrar panel", + "person_alias_heading": "Historial de nombres", + "person_alias_empty": "Aun no se han registrado cambios de nombre.", + "person_alias_type_BIRTH": "Nombre de nacimiento", + "person_alias_type_WIDOWED": "Nombre como viuda/viudo", + "person_alias_type_DIVORCED": "Nombre tras el divorcio", + "person_alias_type_OTHER": "Otro nombre", + "person_alias_add_heading": "Agregar nombre", + "person_alias_label_type": "Tipo", + "person_alias_label_last_name": "Apellido", + "person_alias_label_first_name": "Nombre (opcional)", + "person_alias_btn_add": "Agregar", + "person_alias_delete_title": "Eliminar alias?", + "person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.", + "person_alias_btn_delete": "Eliminar", + "error_alias_not_found": "No se encontro el alias de nombre." } diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index eaa402ed..1adfaa03 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -6,6 +6,7 @@ import * as m from '$lib/paraglide/messages.js'; */ export type ErrorCode = | 'PERSON_NOT_FOUND' + | 'ALIAS_NOT_FOUND' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' | 'FILE_NOT_FOUND' @@ -52,6 +53,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { switch (code) { case 'PERSON_NOT_FOUND': return m.error_person_not_found(); + case 'ALIAS_NOT_FOUND': + return m.error_alias_not_found(); case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found(); case 'DOCUMENT_NO_FILE': -- 2.49.1 From 9e13208ccd5f2b2e4f9d12b4c7610bbf48d5960a Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:24:03 +0200 Subject: [PATCH 08/20] chore(api): regenerate TypeScript API types with alias endpoints Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 124 +++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 36541dba..534c8066 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -196,6 +196,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/aliases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getAliases"]; + put?: never; + post: operations["addAlias"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/notifications/read-all": { parameters: { query?: never; @@ -836,6 +852,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/aliases/{aliasId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["removeAlias"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/annotations/{annotationId}": { parameters: { query?: never; @@ -1014,6 +1046,25 @@ export interface components { currentPassword?: string; newPassword?: string; }; + PersonNameAliasDTO: { + lastName?: string; + firstName?: string; + /** @enum {string} */ + type?: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER"; + }; + PersonNameAlias: { + /** Format: uuid */ + id: string; + person?: components["schemas"]["Person"]; + lastName: string; + firstName?: string; + /** @enum {string} */ + type: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER"; + /** Format: int32 */ + sortOrder: number; + /** Format: date-time */ + createdAt: string; + }; GroupDTO: { name?: string; permissions?: string[]; @@ -1177,10 +1228,10 @@ export interface components { /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ numberOfElements?: number; + first?: boolean; + last?: boolean; empty?: boolean; }; PageableObject: { @@ -1809,6 +1860,54 @@ export interface operations { }; }; }; + getAliases: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["PersonNameAlias"][]; + }; + }; + }; + }; + addAlias: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonNameAliasDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["PersonNameAlias"]; + }; + }; + }; + }; markAllRead: { parameters: { query?: never; @@ -2924,6 +3023,27 @@ export interface operations { }; }; }; + removeAlias: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + aliasId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; deleteAnnotation: { parameters: { query?: never; -- 2.49.1 From 002ee1010a0dab6982751d3a5277f5f7684df596 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:31:07 +0200 Subject: [PATCH 09/20] feat(ui): add Namensverlauf read-only card to person detail page Shows historical name aliases in the left column with type labels and firstName fallback. Fetches aliases in parallel with other data. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/persons/[id]/+page.server.ts | 6 ++- frontend/src/routes/persons/[id]/+page.svelte | 8 ++- .../persons/[id]/NameHistoryCard.svelte | 53 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/persons/[id]/NameHistoryCard.svelte diff --git a/frontend/src/routes/persons/[id]/+page.server.ts b/frontend/src/routes/persons/[id]/+page.server.ts index 6b7e8f79..3c5f0f5c 100644 --- a/frontend/src/routes/persons/[id]/+page.server.ts +++ b/frontend/src/routes/persons/[id]/+page.server.ts @@ -11,10 +11,11 @@ export async function load({ params, fetch, locals }) { g.permissions.includes('WRITE_ALL') ) ?? false; - const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([ + const [personResult, sentDocsResult, receivedDocsResult, aliasesResult] = 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}/received-documents', { params: { path: { id } } }), + api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }) ]); if (!personResult.response.ok) { @@ -26,6 +27,7 @@ export async function load({ params, fetch, locals }) { person: personResult.data!, sentDocuments: sentDocsResult.data ?? [], receivedDocuments: receivedDocsResult.data ?? [], + aliases: aliasesResult.data ?? [], canWrite }; } diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index e14ea2a5..a4e6380b 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -2,6 +2,7 @@ import { m } from '$lib/paraglide/messages.js'; import { SvelteMap } from 'svelte/reactivity'; import PersonCard from './PersonCard.svelte'; +import NameHistoryCard from './NameHistoryCard.svelte'; import CoCorrespondentsList from './CoCorrespondentsList.svelte'; import PersonDocumentList from './PersonDocumentList.svelte'; @@ -65,9 +66,14 @@ const coCorrespondents = $derived.by(() => {
- +
+ {#if data.aliases.length > 0} +
+ +
+ {/if}
diff --git a/frontend/src/routes/persons/[id]/NameHistoryCard.svelte b/frontend/src/routes/persons/[id]/NameHistoryCard.svelte new file mode 100644 index 00000000..84e34852 --- /dev/null +++ b/frontend/src/routes/persons/[id]/NameHistoryCard.svelte @@ -0,0 +1,53 @@ + + +
+

+ {m.person_alias_heading()} +

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

{m.person_alias_empty()}

+ {:else} +
    + {#each sorted as alias (alias.id)} +
  • + {typeLabel(alias.type)} + + {alias.firstName ?? personFirstName} + {alias.lastName} + +
  • + {/each} +
+ {/if} +
-- 2.49.1 From b9105176902e307c58aa4d59673cabf7e70fd677 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:31:41 +0200 Subject: [PATCH 10/20] feat(ui): add alias management to person edit page NameHistoryEditCard with add form (type dropdown + name fields), delete with confirmation modal, and IDOR-safe client-side fetch calls. Placed between Personendaten and DangerZone cards. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/persons/[id]/edit/+page.server.ts | 7 +- .../src/routes/persons/[id]/edit/+page.svelte | 12 +- .../[id]/edit/NameHistoryEditCard.svelte | 231 ++++++++++++++++++ 3 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts index 6b5f9ebc..13e39509 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.server.ts +++ b/frontend/src/routes/persons/[id]/edit/+page.server.ts @@ -12,14 +12,17 @@ export async function load({ params, fetch, locals }) { const { id } = params; const api = createApiClient(fetch); - const result = await api.GET('/api/persons/{id}', { params: { path: { id } } }); + const [result, aliasesResult] = await Promise.all([ + api.GET('/api/persons/{id}', { params: { path: { id } } }), + api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }) + ]); 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! }; + return { person: result.data!, aliases: aliasesResult.data ?? [] }; } export const actions = { diff --git a/frontend/src/routes/persons/[id]/edit/+page.svelte b/frontend/src/routes/persons/[id]/edit/+page.svelte index a3483e4b..ef308f63 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.svelte +++ b/frontend/src/routes/persons/[id]/edit/+page.svelte @@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js'; import { enhance } from '$app/forms'; import PersonEditForm from './PersonEditForm.svelte'; import PersonEditSaveBar from './PersonEditSaveBar.svelte'; +import NameHistoryEditCard from './NameHistoryEditCard.svelte'; import PersonDangerZone from './PersonDangerZone.svelte'; let { data, form } = $props(); @@ -49,8 +50,15 @@ const person = $derived(data.person);
- - + + + + diff --git a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte new file mode 100644 index 00000000..f62ac094 --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte @@ -0,0 +1,231 @@ + + +
+

+ {m.person_alias_heading()} +

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

{m.person_alias_empty()}

+ {:else} +
    + {#each sorted as alias (alias.id)} +
  • + + {typeLabel(alias.type)} + + {alias.firstName ?? personFirstName} + {alias.lastName} + + + {#if canWrite} + + {/if} +
  • + {/each} +
+ {/if} + + {#if canWrite} +
+

+ {m.person_alias_add_heading()} +

+ + {#if addError} +

{addError}

+ {/if} + +
+ + + + + + +
+ +
+
+
+ {/if} +
+ +{#if showDeleteModal} +
+
+

{m.person_alias_delete_title()}

+

{m.person_alias_delete_body()}

+
+ + +
+
+
+{/if} -- 2.49.1 From 59f593280b453fb53bf5f46e4c0093126de25182 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:35:19 +0200 Subject: [PATCH 11/20] fix(test): update person detail loader tests for 4th aliases API call Adds mock for the new GET /api/persons/{id}/aliases call added in the parallel Promise.all. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/persons/[id]/page.server.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/routes/persons/[id]/page.server.spec.ts b/frontend/src/routes/persons/[id]/page.server.spec.ts index 3994fe40..c41ef58b 100644 --- a/frontend/src/routes/persons/[id]/page.server.spec.ts +++ b/frontend/src/routes/persons/[id]/page.server.spec.ts @@ -26,6 +26,7 @@ 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: [] }) } as ReturnType); const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); @@ -45,6 +46,7 @@ describe('person detail load — happy path', () => { }) .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 }); @@ -62,6 +64,7 @@ describe('person detail load — happy path', () => { }) .mockResolvedValueOnce({ response: { ok: false }, data: null }) .mockResolvedValueOnce({ response: { ok: false }, data: null }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); @@ -81,6 +84,7 @@ describe('person detail load — error paths', () => { .mockResolvedValueOnce({ response: { ok: false, status: 404 }, error: null }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); await expect( @@ -97,6 +101,7 @@ describe('person detail load — error paths', () => { .mockResolvedValueOnce({ response: { ok: false, status: 403 }, error: null }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); await expect( -- 2.49.1 From cfb3260e0e13cdd512a48a3ac6bd898df8b24c18 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:40:43 +0200 Subject: [PATCH 12/20] fix(api): add input validation to PersonNameAliasDTO Adds @NotBlank @Size(max=255) on lastName, @NotNull on type, @Valid on controller parameter. Blank/null input now returns 400 instead of reaching the DB constraint. 2 new controller tests. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/PersonController.java | 2 +- .../familienarchiv/dto/PersonNameAliasDTO.java | 9 ++++++--- .../controller/PersonControllerTest.java | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java index 78da866a..6210f529 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -104,7 +104,7 @@ public class PersonController { @PostMapping("/{id}/aliases") @RequirePermission(Permission.WRITE_ALL) - public PersonNameAlias addAlias(@PathVariable UUID id, @RequestBody PersonNameAliasDTO dto) { + public PersonNameAlias addAlias(@PathVariable UUID id, @Valid @RequestBody PersonNameAliasDTO dto) { return personService.addAlias(id, dto); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java index 2ce8a04d..a25ad2f0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java @@ -1,9 +1,12 @@ package org.raddatz.familienarchiv.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import org.raddatz.familienarchiv.model.PersonNameAliasType; public record PersonNameAliasDTO( - String lastName, - String firstName, - PersonNameAliasType type + @NotBlank @Size(max = 255) String lastName, + @Size(max = 255) String firstName, + @NotNull PersonNameAliasType type ) {} 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 cf29b596..bd41be36 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -458,4 +458,22 @@ class PersonControllerTest { mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID())) .andExpect(status().isForbidden()); } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void addAlias_returns400_whenLastNameIsBlank() throws Exception { + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"lastName\":\"\",\"type\":\"BIRTH\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void addAlias_returns400_whenTypeIsNull() throws Exception { + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"lastName\":\"de Gruyter\"}")) + .andExpect(status().isBadRequest()); + } } -- 2.49.1 From 97646a31dffce5c7ca37b4432fa41a413971ee90 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:41:19 +0200 Subject: [PATCH 13/20] fix(ui): always show Namensverlauf card on detail page Removes the {#if} guard so the card with empty state message is always visible for feature discoverability. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/persons/[id]/+page.svelte | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index a4e6380b..b847f1fc 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -69,11 +69,9 @@ const coCorrespondents = $derived.by(() => {
- {#if data.aliases.length > 0} -
- -
- {/if} +
+ +
-- 2.49.1 From 6d837c518c8aad90d498d5d8c59925b95234b8cc Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 13:42:08 +0200 Subject: [PATCH 14/20] fix(a11y): include alias name in delete button aria-label Screen readers now announce which alias is being deleted, e.g. "Entfernen de Gruyter" instead of just "Entfernen". Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/persons/[id]/edit/NameHistoryEditCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte index f62ac094..f602c87a 100644 --- a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte +++ b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte @@ -125,7 +125,7 @@ async function addAlias() { +
+ +
- + {/if} @@ -213,18 +152,33 @@ async function addAlias() {
- + + +
diff --git a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.test.ts b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.test.ts new file mode 100644 index 00000000..ce5510cd --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import NameHistoryEditCard from './NameHistoryEditCard.svelte'; + +const aliases = [ + { id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 }, + { id: 'a2', lastName: 'Schmidt', firstName: 'Maria', type: 'WIDOWED', sortOrder: 1 } +]; + +describe('NameHistoryEditCard', () => { + it('should render alias rows when aliases exist', async () => { + render(NameHistoryEditCard, { aliases, canWrite: true }); + + await expect.element(page.getByText('de Gruyter')).toBeInTheDocument(); + await expect.element(page.getByText('Schmidt')).toBeInTheDocument(); + }); + + it('should show empty state when no aliases', async () => { + render(NameHistoryEditCard, { aliases: [], canWrite: true }); + + const emptyText = document.querySelector('.italic'); + expect(emptyText).not.toBeNull(); + }); + + it('should show add form when canWrite is true', async () => { + render(NameHistoryEditCard, { aliases: [], canWrite: true }); + + const form = document.querySelector('form[action="?/addAlias"]'); + expect(form).not.toBeNull(); + }); + + it('should hide add form when canWrite is false', async () => { + render(NameHistoryEditCard, { aliases: [], canWrite: false }); + + const form = document.querySelector('form[action="?/addAlias"]'); + expect(form).toBeNull(); + }); + + it('should hide delete buttons when canWrite is false', async () => { + render(NameHistoryEditCard, { aliases, canWrite: false }); + + const deleteButtons = document.querySelectorAll('button[aria-label*="Entfernen"]'); + expect(deleteButtons.length).toBe(0); + }); + + it('should show delete buttons when canWrite is true', async () => { + render(NameHistoryEditCard, { aliases, canWrite: true }); + + const deleteButtons = document.querySelectorAll('button[aria-label*="Entfernen"]'); + expect(deleteButtons.length).toBe(2); + }); + + it('should include alias name in delete button aria-label', async () => { + render(NameHistoryEditCard, { aliases: [aliases[0]], canWrite: true }); + + const btn = document.querySelector('button[aria-label*="de Gruyter"]'); + expect(btn).not.toBeNull(); + }); + + it('should show delete modal when delete button is clicked', async () => { + render(NameHistoryEditCard, { aliases: [aliases[0]], canWrite: true }); + + const deleteBtn = document.querySelector('button[aria-label*="de Gruyter"]')!; + deleteBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await expect.element(page.getByText('Alias entfernen?')).toBeInTheDocument(); + }); + + it('should show alias error when provided', async () => { + render(NameHistoryEditCard, { + aliases: [], + canWrite: true, + aliasError: 'Something went wrong' + }); + + await expect.element(page.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('should have required attribute on lastName input', async () => { + render(NameHistoryEditCard, { aliases: [], canWrite: true }); + + const input = document.querySelector('input[name="lastName"]') as HTMLInputElement; + expect(input.required).toBe(true); + }); +}); -- 2.49.1 From f435f2441c4cd85601e64b152103152286c2909c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 16:31:39 +0200 Subject: [PATCH 20/20] fix(model): add @JsonIgnore on PersonNameAlias.person to prevent LazyInitializationException Jackson tried to serialize the lazy Person proxy when returning alias list, causing a "no session" error. The back-reference is only needed for JPA navigation, not for API responses. Co-Authored-By: Claude Sonnet 4.6 --- .../java/org/raddatz/familienarchiv/model/PersonNameAlias.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java index 0b63bead..a894ff3b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java @@ -1,5 +1,6 @@ package org.raddatz.familienarchiv.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.*; @@ -23,6 +24,7 @@ public class PersonNameAlias { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "person_id", nullable = false) + @JsonIgnore private Person person; @Column(name = "last_name", nullable = false) -- 2.49.1