feat: Person name aliases — support name changes over time #181 #206
@@ -5,10 +5,12 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
@@ -92,4 +94,24 @@ public class PersonController {
|
|||||||
}
|
}
|
||||||
personService.mergePersons(id, UUID.fromString(targetIdStr));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -11,6 +11,8 @@ public enum ErrorCode {
|
|||||||
// --- Persons ---
|
// --- Persons ---
|
||||||
/** A person with the given ID does not exist. 404 */
|
/** A person with the given ID does not exist. 404 */
|
||||||
PERSON_NOT_FOUND,
|
PERSON_NOT_FOUND,
|
||||||
|
/** A person name alias with the given ID does not exist. 404 */
|
||||||
|
ALIAS_NOT_FOUND,
|
||||||
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "persons")
|
@Table(name = "persons")
|
||||||
@@ -35,4 +38,12 @@ public class Person {
|
|||||||
|
|
||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
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<>();
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
public enum PersonNameAliasType {
|
||||||
|
BIRTH,
|
||||||
|
WIDOWED,
|
||||||
|
DIVORCED,
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import java.util.UUID;
|
|||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -25,6 +26,12 @@ public class DocumentSpecifications {
|
|||||||
// LEFT JOIN on sender (ManyToOne — no duplicate rows)
|
// LEFT JOIN on sender (ManyToOne — no duplicate rows)
|
||||||
Join<Document, Person> senderJoin = root.join("sender", JoinType.LEFT);
|
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
|
// EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs
|
||||||
Subquery<Long> receiverSub = query.subquery(Long.class);
|
Subquery<Long> receiverSub = query.subquery(Long.class);
|
||||||
Root<Document> receiverRoot = receiverSub.from(Document.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
|
// EXISTS subquery for tag name — avoids duplicate rows for multi-tag docs
|
||||||
Subquery<Long> tagSub = query.subquery(Long.class);
|
Subquery<Long> tagSub = query.subquery(Long.class);
|
||||||
Root<Document> tagRoot = tagSub.from(Document.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(root.get("location")), likePattern),
|
||||||
cb.like(cb.lower(senderJoin.get("lastName")), likePattern),
|
cb.like(cb.lower(senderJoin.get("lastName")), likePattern),
|
||||||
cb.like(cb.lower(senderJoin.get("firstName")), likePattern),
|
cb.like(cb.lower(senderJoin.get("firstName")), likePattern),
|
||||||
|
cb.like(cb.lower(senderAliasJoin.get("lastName")), likePattern),
|
||||||
cb.exists(receiverSub),
|
cb.exists(receiverSub),
|
||||||
|
cb.exists(receiverAliasSub),
|
||||||
cb.exists(tagSub)
|
cb.exists(tagSub)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -15,11 +15,11 @@ import org.springframework.stereotype.Repository;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface PersonRepository extends JpaRepository<Person, UUID> {
|
public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||||
|
|
||||||
// Suche nach String in Vor- ODER Nachnamen, sortiert nach Nachname
|
@Query("SELECT DISTINCT p FROM Person p LEFT JOIN p.nameAliases a WHERE " +
|
||||||
@Query("SELECT p FROM Person p WHERE " +
|
|
||||||
"LOWER(CONCAT(p.firstName,' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"LOWER(CONCAT(p.firstName,' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(CONCAT(p.lastName, ' ', p.firstName)) 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")
|
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||||
List<Person> searchByName(@Param("query") String query);
|
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 documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
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,'%'))
|
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(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(p.alias) 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
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
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.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -22,6 +25,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
public class PersonService {
|
public class PersonService {
|
||||||
|
|
||||||
private final PersonRepository personRepository;
|
private final PersonRepository personRepository;
|
||||||
|
private final PersonNameAliasRepository aliasRepository;
|
||||||
|
|
||||||
public List<PersonSummaryDTO> findAll(String q) {
|
public List<PersonSummaryDTO> findAll(String q) {
|
||||||
if (q == null) {
|
if (q == null) {
|
||||||
@@ -137,4 +141,35 @@ public class PersonService {
|
|||||||
|
|
||||||
personRepository.deleteById(sourceId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
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.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
@@ -393,4 +396,84 @@ class PersonControllerTest {
|
|||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import org.raddatz.familienarchiv.config.FlywayConfig;
|
|||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
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.raddatz.familienarchiv.model.Tag;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
@@ -28,6 +30,7 @@ class DocumentSpecificationsTest {
|
|||||||
|
|
||||||
@Autowired DocumentRepository documentRepository;
|
@Autowired DocumentRepository documentRepository;
|
||||||
@Autowired PersonRepository personRepository;
|
@Autowired PersonRepository personRepository;
|
||||||
|
@Autowired PersonNameAliasRepository aliasRepository;
|
||||||
@Autowired TagRepository tagRepository;
|
@Autowired TagRepository tagRepository;
|
||||||
|
|
||||||
private Person sender;
|
private Person sender;
|
||||||
@@ -325,4 +328,27 @@ class DocumentSpecificationsTest {
|
|||||||
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED)));
|
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED)));
|
||||||
assertThat(result).isEmpty();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import org.raddatz.familienarchiv.config.FlywayConfig;
|
|||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
@@ -29,6 +31,9 @@ class PersonRepositoryTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PersonRepository personRepository;
|
private PersonRepository personRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PersonNameAliasRepository aliasRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private DocumentRepository documentRepository;
|
private DocumentRepository documentRepository;
|
||||||
|
|
||||||
@@ -383,4 +388,56 @@ class PersonRepositoryTest {
|
|||||||
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||||
assertThat(documentRepository.findById(doc2.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
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.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@@ -25,6 +29,7 @@ import static org.mockito.Mockito.*;
|
|||||||
class PersonServiceTest {
|
class PersonServiceTest {
|
||||||
|
|
||||||
@Mock PersonRepository personRepository;
|
@Mock PersonRepository personRepository;
|
||||||
|
@Mock PersonNameAliasRepository aliasRepository;
|
||||||
@InjectMocks PersonService personService;
|
@InjectMocks PersonService personService;
|
||||||
|
|
||||||
// ─── getById ─────────────────────────────────────────────────────────────
|
// ─── getById ─────────────────────────────────────────────────────────────
|
||||||
@@ -436,4 +441,99 @@ class PersonServiceTest {
|
|||||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
verify(personRepository).deleteReceiverReferences(sourceId);
|
||||||
verify(personRepository).deleteById(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,5 +479,20 @@
|
|||||||
"scan_collapse": "Scan verkleinern",
|
"scan_collapse": "Scan verkleinern",
|
||||||
"transcription_empty_title": "Noch keine Transkription",
|
"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_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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,5 +479,20 @@
|
|||||||
"scan_collapse": "Collapse scan",
|
"scan_collapse": "Collapse scan",
|
||||||
"transcription_empty_title": "No transcription yet",
|
"transcription_empty_title": "No transcription yet",
|
||||||
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,5 +479,20 @@
|
|||||||
"scan_collapse": "Reducir escaneo",
|
"scan_collapse": "Reducir escaneo",
|
||||||
"transcription_empty_title": "Sin transcripcion",
|
"transcription_empty_title": "Sin transcripcion",
|
||||||
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una 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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as m from '$lib/paraglide/messages.js';
|
|||||||
*/
|
*/
|
||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
| 'PERSON_NOT_FOUND'
|
| 'PERSON_NOT_FOUND'
|
||||||
|
| 'ALIAS_NOT_FOUND'
|
||||||
| 'DOCUMENT_NOT_FOUND'
|
| 'DOCUMENT_NOT_FOUND'
|
||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
@@ -52,6 +53,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
switch (code) {
|
switch (code) {
|
||||||
case 'PERSON_NOT_FOUND':
|
case 'PERSON_NOT_FOUND':
|
||||||
return m.error_person_not_found();
|
return m.error_person_not_found();
|
||||||
|
case 'ALIAS_NOT_FOUND':
|
||||||
|
return m.error_alias_not_found();
|
||||||
case 'DOCUMENT_NOT_FOUND':
|
case 'DOCUMENT_NOT_FOUND':
|
||||||
return m.error_document_not_found();
|
return m.error_document_not_found();
|
||||||
case 'DOCUMENT_NO_FILE':
|
case 'DOCUMENT_NO_FILE':
|
||||||
|
|||||||
@@ -196,6 +196,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/notifications/read-all": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -836,6 +852,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/api/documents/{documentId}/annotations/{annotationId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1014,6 +1046,25 @@ export interface components {
|
|||||||
currentPassword?: string;
|
currentPassword?: string;
|
||||||
newPassword?: 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: {
|
GroupDTO: {
|
||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
@@ -1177,10 +1228,10 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
number?: number;
|
number?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
first?: boolean;
|
|
||||||
last?: boolean;
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
numberOfElements?: number;
|
numberOfElements?: number;
|
||||||
|
first?: boolean;
|
||||||
|
last?: boolean;
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
};
|
};
|
||||||
PageableObject: {
|
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: {
|
markAllRead: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
deleteAnnotation: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ export async function load({ params, fetch, locals }) {
|
|||||||
g.permissions.includes('WRITE_ALL')
|
g.permissions.includes('WRITE_ALL')
|
||||||
) ?? false;
|
) ?? 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}', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/documents', { 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) {
|
if (!personResult.response.ok) {
|
||||||
@@ -26,6 +27,7 @@ export async function load({ params, fetch, locals }) {
|
|||||||
person: personResult.data!,
|
person: personResult.data!,
|
||||||
sentDocuments: sentDocsResult.data ?? [],
|
sentDocuments: sentDocsResult.data ?? [],
|
||||||
receivedDocuments: receivedDocsResult.data ?? [],
|
receivedDocuments: receivedDocsResult.data ?? [],
|
||||||
|
aliases: aliasesResult.data ?? [],
|
||||||
canWrite
|
canWrite
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import PersonCard from './PersonCard.svelte';
|
import PersonCard from './PersonCard.svelte';
|
||||||
|
import NameHistoryCard from './NameHistoryCard.svelte';
|
||||||
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||||
import PersonDocumentList from './PersonDocumentList.svelte';
|
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||||
|
|
||||||
@@ -65,9 +66,12 @@ const coCorrespondents = $derived.by(() => {
|
|||||||
|
|
||||||
<!-- 2-column layout on large screens -->
|
<!-- 2-column layout on large screens -->
|
||||||
<div class="lg:grid lg:grid-cols-[35%_65%] lg:gap-8">
|
<div class="lg:grid lg:grid-cols-[35%_65%] lg:gap-8">
|
||||||
<!-- Left column: Person card -->
|
<!-- Left column: Person card + name history -->
|
||||||
<div>
|
<div>
|
||||||
<PersonCard person={person} canWrite={data.canWrite} />
|
<PersonCard person={person} canWrite={data.canWrite} />
|
||||||
|
<div class="mt-6">
|
||||||
|
<NameHistoryCard aliases={data.aliases} personFirstName={person.firstName} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right column: correspondents + documents -->
|
<!-- Right column: correspondents + documents -->
|
||||||
|
|||||||
53
frontend/src/routes/persons/[id]/NameHistoryCard.svelte
Normal file
53
frontend/src/routes/persons/[id]/NameHistoryCard.svelte
Normal 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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,14 +12,17 @@ export async function load({ params, fetch, locals }) {
|
|||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
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) {
|
if (!result.response.ok) {
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
throw error(result.response.status, getErrorMessage(code));
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { person: result.data! };
|
return { person: result.data!, aliases: aliasesResult.data ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -83,5 +86,53 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/persons/${targetPersonId}`);
|
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 };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import PersonEditForm from './PersonEditForm.svelte';
|
import PersonEditForm from './PersonEditForm.svelte';
|
||||||
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
||||||
|
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
|
||||||
import PersonDangerZone from './PersonDangerZone.svelte';
|
import PersonDangerZone from './PersonDangerZone.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -41,16 +42,18 @@ const person = $derived(data.person);
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<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">
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.persons_section_details()}
|
{m.persons_section_details()}
|
||||||
</h2>
|
</h2>
|
||||||
<PersonEditForm person={person} />
|
<PersonEditForm person={person} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PersonDangerZone person={person} form={form} />
|
|
||||||
|
|
||||||
<PersonEditSaveBar discardHref="/persons/{person.id}" />
|
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|||||||
185
frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte
Normal file
185
frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte
Normal 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}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { discardHref }: { discardHref: string } = $props();
|
let { discardHref, formId }: { discardHref: string; formId?: string } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Sticky full-bleed save bar -->
|
|
||||||
<div
|
<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">
|
<a href={discardHref} class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
|
||||||
{m.person_discard_changes()}
|
{m.person_discard_changes()}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
form={formId}
|
||||||
formaction="?/update"
|
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"
|
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('person detail load — happy path', () => {
|
|||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1', title: 'Brief' }] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1', title: 'Brief' }] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
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: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
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: false }, data: null })
|
.mockResolvedValueOnce({ response: { ok: false }, data: null })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
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: false, status: 404 }, error: null })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -97,6 +101,7 @@ describe('person detail load — error paths', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: false, status: 403 }, error: null })
|
.mockResolvedValueOnce({ response: { ok: false, status: 403 }, error: null })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
Reference in New Issue
Block a user