Compare commits

...

20 Commits

Author SHA1 Message Date
Marcel
f435f2441c fix(model): add @JsonIgnore on PersonNameAlias.person to prevent LazyInitializationException
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1s
CI / Backend Unit Tests (pull_request) Failing after 1s
CI / Unit & Component Tests (push) Failing after 3s
CI / Backend Unit Tests (push) Failing after 1s
Jackson tried to serialize the lazy Person proxy when returning
alias list, causing a "no session" error. The back-reference is
only needed for JPA navigation, not for API responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:31:39 +02:00
Marcel
e204ed89b6 fix(ui): switch alias operations from client fetch to form actions
Some checks failed
CI / Unit & Component Tests (push) Failing after 1s
CI / Backend Unit Tests (push) Failing after 2s
CI / Unit & Component Tests (pull_request) Failing after 1s
CI / Backend Unit Tests (pull_request) Failing after 1s
Replaces raw client-side fetch with SvelteKit form actions
(addAlias, removeAlias) using the server-side API client for
proper auth handling. 10 new component tests for NameHistoryEditCard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:05:56 +02:00
Marcel
036843bf8f fix(ui): use mt-6 on save bar to match card spacing
Some checks failed
CI / Unit & Component Tests (push) Failing after 3s
CI / Backend Unit Tests (push) Failing after 2s
CI / Unit & Component Tests (pull_request) Failing after 2s
CI / Backend Unit Tests (pull_request) Failing after 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:01:43 +02:00
Marcel
9027f60760 fix(ui): use card-style save bar with mt-4 instead of full-bleed
Some checks failed
CI / Unit & Component Tests (push) Failing after 3s
CI / Backend Unit Tests (push) Failing after 1s
CI / Unit & Component Tests (pull_request) Failing after 1s
CI / Backend Unit Tests (pull_request) Failing after 1s
Removes -mx-4 negative margin and switches to the card pattern
(rounded border, shadow-sm, mt-4) so the save bar matches the
width of the other cards on the edit page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:59:55 +02:00
Marcel
0f5eebec29 fix(ui): move save bar to end of edit page after alias and danger zone
Some checks failed
CI / Unit & Component Tests (push) Failing after 2s
CI / Backend Unit Tests (push) Failing after 1s
CI / Unit & Component Tests (pull_request) Failing after 1s
CI / Backend Unit Tests (pull_request) Failing after 1s
Uses HTML form attribute to associate the submit button with the
person-edit-form from outside the form tag. Page now reads:
Personendaten -> Namensverlauf -> Danger zone -> Save bar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:59:05 +02:00
Marcel
f0eb3a76be test(ui): add component tests for NameHistoryCard
Some checks failed
CI / Unit & Component Tests (push) Failing after 3s
CI / Backend Unit Tests (push) Failing after 1s
CI / Unit & Component Tests (pull_request) Failing after 2s
CI / Backend Unit Tests (pull_request) Failing after 2s
Verifies alias rendering, empty state, firstName fallback,
and type label display. 5 browser-based Svelte tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:43:09 +02:00
Marcel
6d837c518c fix(a11y): include alias name in delete button aria-label
Screen readers now announce which alias is being deleted, e.g.
"Entfernen de Gruyter" instead of just "Entfernen".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:42:08 +02:00
Marcel
97646a31df fix(ui): always show Namensverlauf card on detail page
Removes the {#if} guard so the card with empty state message is
always visible for feature discoverability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:41:19 +02:00
Marcel
cfb3260e0e fix(api): add input validation to PersonNameAliasDTO
Adds @NotBlank @Size(max=255) on lastName, @NotNull on type,
@Valid on controller parameter. Blank/null input now returns
400 instead of reaching the DB constraint. 2 new controller tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:40:43 +02:00
Marcel
59f593280b fix(test): update person detail loader tests for 4th aliases API call
Some checks failed
CI / Unit & Component Tests (push) Failing after 2s
CI / Backend Unit Tests (push) Failing after 1s
CI / Unit & Component Tests (pull_request) Failing after 1s
CI / Backend Unit Tests (pull_request) Failing after 2s
Adds mock for the new GET /api/persons/{id}/aliases call added
in the parallel Promise.all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:35:19 +02:00
Marcel
b910517690 feat(ui): add alias management to person edit page
NameHistoryEditCard with add form (type dropdown + name fields),
delete with confirmation modal, and IDOR-safe client-side fetch
calls. Placed between Personendaten and DangerZone cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:31:41 +02:00
Marcel
002ee1010a feat(ui): add Namensverlauf read-only card to person detail page
Shows historical name aliases in the left column with type labels
and firstName fallback. Fetches aliases in parallel with other data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:31:07 +02:00
Marcel
9e13208ccd chore(api): regenerate TypeScript API types with alias endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:24:03 +02:00
Marcel
f396e079a5 feat(i18n): add alias type labels and section strings for de/en/es
Adds 16 new keys per language: alias type labels (BIRTH, WIDOWED,
DIVORCED, OTHER), section heading, empty state, add form labels,
delete confirmation, and ALIAS_NOT_FOUND error code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:21:21 +02:00
Marcel
90c9ac9357 feat(search): extend document text search to match alias last names
Adds sender alias LEFT JOIN and receiver alias EXISTS subquery to
DocumentSpecifications.hasText(). Uses entity-graph navigation via
Person.nameAliases (@OneToMany) to avoid a separate DB roundtrip
while respecting domain boundaries. 2 new integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:18:31 +02:00
Marcel
db61d6b77f feat(search): extend person search to include alias last names
Adds LEFT JOIN to person_name_aliases in both searchByName (JPQL)
and searchWithDocumentCount (native SQL). Uses DISTINCT/GROUP BY
to prevent duplicate results. 4 new integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:12:54 +02:00
Marcel
a1d63bbc42 feat(api): add GET/POST/DELETE /api/persons/{id}/aliases endpoints
GET returns aliases (no permission required), POST requires
WRITE_ALL, DELETE requires WRITE_ALL. 5 new controller tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:09:58 +02:00
Marcel
0fc568dd9f feat(service): add alias CRUD methods to PersonService
getAliases (sorted by sort_order), addAlias (auto-incrementing
sort_order), removeAlias (with IDOR protection verifying alias
belongs to the given person). All TDD with 7 new unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:14 +02:00
Marcel
765cbfbaaf feat(model): add PersonNameAlias entity, type enum, repository, DTO
Introduces the alias domain model: entity with @ManyToOne to Person,
@OneToMany on Person for JPA graph navigation, repository with
sort_order queries, input DTO, and ALIAS_NOT_FOUND error code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:04:38 +02:00
Marcel
22fe9600a1 feat(migration): V21 add person_name_aliases table with pg_trgm indexes
Creates the alias table for historical name changes (marriage,
widowhood, etc.) and adds GIN trigram indexes on both the new
alias table and the existing persons table for substring search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:02:51 +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(