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..6210f529 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, @Valid @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/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..a25ad2f0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonNameAliasDTO.java @@ -0,0 +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( + @NotBlank @Size(max = 255) String lastName, + @Size(max = 255) String firstName, + @NotNull 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..a894ff3b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAlias.java @@ -0,0 +1,50 @@ +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 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) + @JsonIgnore + 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/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/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); +} 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/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/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); 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..bd41be36 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,84 @@ 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()); + } + + @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()); + } } 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(); + } } 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"); + } } 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); + } } 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': 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; 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..b847f1fc 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,12 @@ const coCorrespondents = $derived.by(() => {
- +
+
+ +
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} +
diff --git a/frontend/src/routes/persons/[id]/NameHistoryCard.svelte.test.ts b/frontend/src/routes/persons/[id]/NameHistoryCard.svelte.test.ts new file mode 100644 index 00000000..044c4614 --- /dev/null +++ b/frontend/src/routes/persons/[id]/NameHistoryCard.svelte.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import NameHistoryCard from './NameHistoryCard.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('NameHistoryCard', () => { + it('should render one row per alias', async () => { + render(NameHistoryCard, { aliases, personFirstName: 'Clara' }); + + 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(NameHistoryCard, { aliases: [], personFirstName: 'Clara' }); + + const emptyText = document.querySelector('.italic'); + expect(emptyText).not.toBeNull(); + expect(emptyText!.textContent!.length).toBeGreaterThan(0); + }); + + it('should use personFirstName when alias firstName is null', async () => { + render(NameHistoryCard, { + aliases: [{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 }], + personFirstName: 'Clara' + }); + + await expect.element(page.getByText('Clara')).toBeInTheDocument(); + }); + + it('should use alias firstName when provided', async () => { + render(NameHistoryCard, { + aliases: [ + { id: 'a1', lastName: 'Schmidt', firstName: 'Maria', type: 'WIDOWED', sortOrder: 0 } + ], + personFirstName: 'Clara' + }); + + await expect.element(page.getByText('Maria')).toBeInTheDocument(); + }); + + it('should show type labels', async () => { + render(NameHistoryCard, { + aliases: [{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 }], + personFirstName: 'Clara' + }); + + await expect.element(page.getByText('geborene/r')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts index 6b5f9ebc..e814737a 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 = { @@ -83,5 +86,53 @@ export const actions = { } throw redirect(303, `/persons/${targetPersonId}`); + }, + + addAlias: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const lastName = formData.get('lastName')?.toString().trim(); + const firstName = formData.get('firstName')?.toString().trim() || undefined; + const type = formData.get('type')?.toString(); + + if (!lastName) { + return fail(400, { aliasError: 'Nachname ist ein Pflichtfeld.' }); + } + if (!type) { + return fail(400, { aliasError: 'Art ist ein Pflichtfeld.' }); + } + + const api = createApiClient(fetch); + const result = await api.POST('/api/persons/{id}/aliases', { + params: { path: { id: params.id } }, + body: { lastName, firstName, type: type as 'BIRTH' | 'WIDOWED' | 'DIVORCED' | 'OTHER' } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { aliasError: getErrorMessage(code) }); + } + + return { aliasSuccess: true }; + }, + + removeAlias: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const aliasId = formData.get('aliasId')?.toString(); + + if (!aliasId) { + return fail(400, { aliasError: 'Alias ID fehlt.' }); + } + + const api = createApiClient(fetch); + const result = await api.DELETE('/api/persons/{id}/aliases/{aliasId}', { + params: { path: { id: params.id, aliasId } } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { aliasError: getErrorMessage(code) }); + } + + return { aliasSuccess: true }; } }; diff --git a/frontend/src/routes/persons/[id]/edit/+page.svelte b/frontend/src/routes/persons/[id]/edit/+page.svelte index a3483e4b..dd0f539e 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(); @@ -41,16 +42,18 @@ const person = $derived(data.person);
{/if} -
+

{m.persons_section_details()}

- - - - + + + + + + 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..a7e6c185 --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte @@ -0,0 +1,185 @@ + + +
+

+ {m.person_alias_heading()} +

+ + {#if aliasError} +

{aliasError}

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

{m.person_alias_empty()}

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

+ {m.person_alias_add_heading()} +

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

{m.person_alias_delete_title()}

+

{m.person_alias_delete_body()}

+
+ +
{ + return async ({ update }) => { + showDeleteModal = false; + deleteTargetId = null; + await update(); + }; + }} + > + + +
+
+
+
+{/if} 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); + }); +}); diff --git a/frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte b/frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte index 318b1e4f..42462753 100644 --- a/frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte +++ b/frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte @@ -1,18 +1,18 @@ -
{m.person_discard_changes()}