feat: Person name aliases — support name changes over time #181 #206

Merged
marcel merged 20 commits from feat/issue-181-person-name-aliases into main 2026-04-07 16:44:25 +02:00
30 changed files with 1100 additions and 21 deletions

View File

@@ -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<PersonNameAlias> 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);
}
}

View File

@@ -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
) {}

View File

@@ -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 */

View File

@@ -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<PersonNameAlias> nameAliases = new ArrayList<>();
}

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.model;
public enum PersonNameAliasType {
BIRTH,
WIDOWED,
DIVORCED,
OTHER
}

View File

@@ -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<Document, Person> 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<Person, PersonNameAlias> senderAliasJoin = senderJoin.join("nameAliases", JoinType.LEFT);
// EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs
Subquery<Long> receiverSub = query.subquery(Long.class);
Root<Document> receiverRoot = receiverSub.from(Document.class);
@@ -38,6 +45,17 @@ public class DocumentSpecifications {
)
);
// EXISTS subquery for receiver alias name
Subquery<Long> receiverAliasSub = query.subquery(Long.class);
Root<Document> receiverAliasRoot = receiverAliasSub.from(Document.class);
Join<Document, Person> recAliasPersonJoin = receiverAliasRoot.join("receivers");
Join<Person, PersonNameAlias> 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<Long> tagSub = query.subquery(Long.class);
Root<Document> 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)
);
};

View File

@@ -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<PersonNameAlias, UUID> {
List<PersonNameAlias> findByPersonIdOrderBySortOrderAscCreatedAtAsc(UUID personId);
@Query("SELECT COALESCE(MAX(a.sortOrder), -1) FROM PersonNameAlias a WHERE a.person.id = :personId")
int findMaxSortOrder(UUID personId);
}

View File

@@ -15,11 +15,11 @@ import org.springframework.stereotype.Repository;
@Repository
public interface PersonRepository extends JpaRepository<Person, UUID> {
// 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<Person> searchByName(@Param("query") String query);
@@ -51,9 +51,12 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
(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)

View File

@@ -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<PersonSummaryDTO> findAll(String q) {
if (q == null) {
@@ -137,4 +141,35 @@ public class PersonService {
personRepository.deleteById(sourceId);
}
// ─── Alias management ───────────────────────────────────────────────────
public List<PersonNameAlias> 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);
}
}

View File

@@ -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);

View File

@@ -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());
}
}

View File

@@ -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<Document> 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<Document> 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<Document> result = documentRepository.findAll(Specification.where(hasText("de Gruyter")));
assertThat(result).isNotEmpty();
}
}

View File

@@ -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<Person> 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<Person> 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<Person> 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<PersonSummaryDTO> results = personRepository.searchWithDocumentCount("de Gruyter");
assertThat(results).hasSize(1);
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
}
}

View File

@@ -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<PersonNameAlias> aliases = List.of(
PersonNameAlias.builder().id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
when(aliasRepository.findByPersonIdOrderBySortOrderAscCreatedAtAsc(personId)).thenReturn(aliases);
List<PersonNameAlias> 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);
}
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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':

View File

@@ -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;

View File

@@ -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
};
}

View File

@@ -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(() => {
<!-- 2-column layout on large screens -->
<div class="lg:grid lg:grid-cols-[35%_65%] lg:gap-8">
<!-- Left column: Person card -->
<!-- Left column: Person card + name history -->
<div>
<PersonCard person={person} canWrite={data.canWrite} />
<div class="mt-6">
<NameHistoryCard aliases={data.aliases} personFirstName={person.firstName} />
</div>
</div>
<!-- Right column: correspondents + documents -->

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
aliases: Array<{
id: string;
lastName: string;
firstName?: string | null;
type: string;
sortOrder: number;
}>;
personFirstName: string;
}
let { aliases, personFirstName }: Props = $props();
let sorted = $derived([...aliases].sort((a, b) => a.sortOrder - b.sortOrder));
function typeLabel(type: string): string {
switch (type) {
case 'BIRTH':
return m.person_alias_type_BIRTH();
case 'WIDOWED':
return m.person_alias_type_WIDOWED();
case 'DIVORCED':
return m.person_alias_type_DIVORCED();
default:
return m.person_alias_type_OTHER();
}
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.person_alias_heading()}
</h2>
{#if sorted.length === 0}
<p class="text-sm text-ink-2 italic">{m.person_alias_empty()}</p>
{:else}
<ul class="space-y-2">
{#each sorted as alias (alias.id)}
<li>
<span class="text-ink-2 italic">{typeLabel(alias.type)}</span>
<span class="font-serif text-ink">
{alias.firstName ?? personFirstName}
{alias.lastName}
</span>
</li>
{/each}
</ul>
{/if}
</div>

View File

@@ -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();
});
});

View File

@@ -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 };
}
};

View File

@@ -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);
</div>
{/if}
<form method="POST" use:enhance>
<form id="person-edit-form" method="POST" use:enhance>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.persons_section_details()}
</h2>
<PersonEditForm person={person} />
</div>
<PersonDangerZone person={person} form={form} />
<PersonEditSaveBar discardHref="/persons/{person.id}" />
</form>
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
<PersonDangerZone person={person} form={form} />
<PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" />
</div>

View File

@@ -0,0 +1,185 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
interface Props {
aliases: Array<{
id: string;
lastName: string;
firstName?: string | null;
type: string;
sortOrder: number;
}>;
canWrite: boolean;
aliasError?: string | null;
}
let { aliases, canWrite, aliasError = null }: Props = $props();
let sorted = $derived([...aliases].sort((a, b) => a.sortOrder - b.sortOrder));
let showDeleteModal = $state(false);
let deleteTargetId: string | null = $state(null);
function typeLabel(type: string): string {
switch (type) {
case 'BIRTH':
return m.person_alias_type_BIRTH();
case 'WIDOWED':
return m.person_alias_type_WIDOWED();
case 'DIVORCED':
return m.person_alias_type_DIVORCED();
default:
return m.person_alias_type_OTHER();
}
}
function confirmDelete(id: string) {
deleteTargetId = id;
showDeleteModal = true;
}
</script>
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.person_alias_heading()}
</h2>
{#if aliasError}
<p class="mb-3 text-sm text-red-600">{aliasError}</p>
{/if}
{#if sorted.length === 0}
<p class="text-sm text-ink-2 italic">{m.person_alias_empty()}</p>
{:else}
<ul class="space-y-2">
{#each sorted as alias (alias.id)}
<li class="flex items-center justify-between">
<span>
<span class="text-ink-2 italic">{typeLabel(alias.type)}</span>
<span class="font-serif text-ink">
{#if alias.firstName}{alias.firstName}{/if}
{alias.lastName}
</span>
</span>
{#if canWrite}
<button
type="button"
onclick={() => confirmDelete(alias.id)}
aria-label="{m.person_alias_btn_delete()} {alias.lastName}"
class="ml-4 inline-flex min-h-[44px] min-w-[44px] items-center justify-center text-red-400 transition-colors hover:text-red-600"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</li>
{/each}
</ul>
{/if}
{#if canWrite}
<div class="mt-4 border-t border-line pt-4">
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.person_alias_add_heading()}
</h3>
<form method="POST" action="?/addAlias" use:enhance>
<div class="grid gap-3 md:grid-cols-2">
<label class="block">
<span class="text-xs font-medium text-ink-2">{m.person_alias_label_type()}</span>
<select
name="type"
class="mt-1 block w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:border-primary focus:outline-none"
>
<option value="BIRTH">{m.person_alias_type_BIRTH()}</option>
<option value="WIDOWED">{m.person_alias_type_WIDOWED()}</option>
<option value="DIVORCED">{m.person_alias_type_DIVORCED()}</option>
<option value="OTHER">{m.person_alias_type_OTHER()}</option>
</select>
</label>
<label class="block">
<span class="text-xs font-medium text-ink-2">{m.person_alias_label_last_name()}</span>
<input
type="text"
name="lastName"
required
class="mt-1 block w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<label class="block">
<span class="text-xs font-medium text-ink-2">{m.person_alias_label_first_name()}</span>
<input
type="text"
name="firstName"
class="mt-1 block w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<div class="flex items-end">
<button
type="submit"
class="w-full rounded-sm bg-primary px-4 py-2 text-sm font-medium text-primary-fg transition-colors hover:bg-primary/80"
>
{m.person_alias_btn_add()}
</button>
</div>
</div>
</form>
</div>
{/if}
</div>
{#if showDeleteModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg">
<h3 class="mb-2 font-serif text-lg text-ink">{m.person_alias_delete_title()}</h3>
<p class="mb-6 text-sm text-ink-2">{m.person_alias_delete_body()}</p>
<div class="flex items-center justify-end gap-3">
<button
type="button"
onclick={() => {
showDeleteModal = false;
deleteTargetId = null;
}}
class="rounded-sm border border-line px-4 py-2 text-sm font-medium text-ink-2 transition-colors hover:bg-muted"
>
{m.btn_cancel()}
</button>
<form
method="POST"
action="?/removeAlias"
use:enhance={() => {
return async ({ update }) => {
showDeleteModal = false;
deleteTargetId = null;
await update();
};
}}
>
<input type="hidden" name="aliasId" value={deleteTargetId} />
<button
type="submit"
class="rounded-sm bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
>
{m.person_alias_btn_delete()}
</button>
</form>
</div>
</div>
</div>
{/if}

View File

@@ -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);
});
});

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { discardHref }: { discardHref: string } = $props();
let { discardHref, formId }: { discardHref: string; formId?: string } = $props();
</script>
<!-- Sticky full-bleed save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
class="mt-6 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
>
<a href={discardHref} class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
{m.person_discard_changes()}
</a>
<button
type="submit"
form={formId}
formaction="?/update"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
>

View File

@@ -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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
await expect(