feat: PersonNameParser enhancements and Person model refactor (#209-#213) #215

Merged
marcel merged 25 commits from feat/issues-209-213-person-parser-enhancements into main 2026-04-08 18:48:00 +02:00
61 changed files with 1442 additions and 157 deletions

View File

@@ -9,11 +9,18 @@ import java.util.UUID;
*/
public interface PersonSummaryDTO {
UUID getId();
String getTitle();
String getFirstName();
String getLastName();
String getPersonType();
String getAlias();
Integer getBirthYear();
Integer getDeathYear();
String getNotes();
long getDocumentCount();
default String getDisplayName() {
return org.raddatz.familienarchiv.model.DisplayNameFormatter.format(
getTitle(), getFirstName(), getLastName());
}
}

View File

@@ -5,6 +5,8 @@ import lombok.Data;
@Data
public class PersonUpdateDTO {
@Size(max = 50)
private String title;
@Size(max = 100)
private String firstName;
@Size(max = 100)

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.model;
public class DisplayNameFormatter {
public static String format(String title, String firstName, String lastName) {
StringBuilder sb = new StringBuilder();
if (title != null) sb.append(title).append(" ");
if (firstName != null) sb.append(firstName).append(" ");
sb.append(lastName);
return sb.toString().trim();
}
}

View File

@@ -21,14 +21,22 @@ public class Person {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Column(name = "title")
private String title;
@Column(nullable = true)
private String firstName;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String lastName;
@Enumerated(EnumType.STRING)
@Column(name = "person_type", nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private PersonType personType = PersonType.PERSON;
// Optional: Aliasse für die Suche (z.B. "Opa Hans")
private String alias;
@@ -46,4 +54,10 @@ public class Person {
@JsonIgnore
@Builder.Default
private List<PersonNameAlias> nameAliases = new ArrayList<>();
@Transient
@Schema(accessMode = Schema.AccessMode.READ_ONLY, requiredMode = Schema.RequiredMode.REQUIRED)
public String getDisplayName() {
return DisplayNameFormatter.format(title, firstName, lastName);
}
}

View File

@@ -4,5 +4,6 @@ public enum PersonNameAliasType {
BIRTH,
WIDOWED,
DIVORCED,
MAIDEN_NAME,
OTHER
}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.model;
public enum PersonType {
PERSON,
INSTITUTION,
GROUP,
UNKNOWN,
SKIP
}

View File

@@ -41,7 +41,7 @@ public class DocumentSpecifications {
cb.equal(receiverRoot.get("id"), root.get("id")),
cb.or(
cb.like(cb.lower(receiverJoin.get("lastName")), likePattern),
cb.like(cb.lower(receiverJoin.get("firstName")), likePattern)
cb.like(cb.lower(cb.coalesce(receiverJoin.get("firstName"), "")), likePattern)
)
);
@@ -74,7 +74,7 @@ public class DocumentSpecifications {
cb.like(cb.lower(root.get("transcription")), likePattern),
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(cb.coalesce(senderJoin.get("firstName"), "")), likePattern),
cb.like(cb.lower(senderAliasJoin.get("lastName")), likePattern),
cb.exists(receiverSub),
cb.exists(receiverAliasSub),

View File

@@ -16,8 +16,8 @@ import org.springframework.stereotype.Repository;
public interface PersonRepository extends JpaRepository<Person, UUID> {
@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(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"ORDER BY p.lastName ASC, p.firstName ASC")
@@ -35,7 +35,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// --- PersonSummaryDTO with document count ---
@Query(value = """
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
(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
@@ -46,17 +47,18 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
List<PersonSummaryDTO> findAllWithDocumentCount();
@Query(value = """
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
(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,'%'))
WHERE LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(CONCAT(p.last_name,' ',COALESCE(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
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)
@@ -98,8 +100,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
) shared ON shared.other_id = p.id
WHERE p.id != :personId
AND (LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:q,'%'))
AND (LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:q,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%')))
GROUP BY p.id
ORDER BY COUNT(DISTINCT shared.doc_id) DESC

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import java.util.Objects;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
@@ -301,6 +302,7 @@ public class MassImportService {
Person sender = senderRaw.isBlank() ? null : findOrCreatePerson(senderRaw);
List<Person> receivers = PersonNameParser.parseReceivers(receiversRaw).stream()
.map(this::findOrCreatePerson)
.filter(Objects::nonNull)
.toList();
Tag tag = null;

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -16,14 +17,21 @@ public class PersonNameParser {
// Known last names in this archive, longest first to avoid partial matches
// (e.g. "de Gruyter" must be checked before any single-word name)
static final List<String> KNOWN_LAST_NAMES = List.of(
"von der Heide", "von Massenbach", "von Geldern", "von Gelden", "von Staa",
"de Gruyter", "Dieckmann", "Gruber", "Müller", "Wolff", "Cram");
private static final Pattern GEB_PATTERN = Pattern.compile("\\s+geb\\.\\s+\\S+");
private static final Pattern GEB_PATTERN = Pattern.compile(",?\\s*geb\\.?\\s+(.+)$");
private static final Pattern PAREN_LAST_NAME = Pattern.compile("\\(([^)]+)\\)\\s*$");
private static final Pattern MULTI_SEPARATOR = Pattern.compile("\\s+(?:und|u)\\s+");
private static final Pattern SLASH_SEPARATOR = Pattern.compile("//");
public record SplitName(String firstName, String lastName) {}
public record SplitName(
String title,
String firstName,
String lastName,
String maidenName,
String annotation
) {}
/**
* Parses the "An" field from the ODS into individual normalised name strings.
@@ -53,7 +61,14 @@ public class PersonNameParser {
// 1. Strip "geb. Xxx" maiden-name annotations
String cleaned = GEB_PATTERN.matcher(raw).replaceAll("").trim();
// 2. Extract parenthesised last name override, e.g. "(Gruber)"
// 2. If no multi-separator present, this is a single person — leave parens
// intact for split()'s annotation extraction
if (!MULTI_SEPARATOR.matcher(cleaned).find()) {
return List.of(cleaned);
}
// 3. Extract parenthesised last name override, e.g. "(Gruber)"
// Only applies to multi-person entries like "Hedi und Tutu (Gruber)"
String sharedLastName = null;
Matcher parenMatcher = PAREN_LAST_NAME.matcher(cleaned);
if (parenMatcher.find()) {
@@ -61,11 +76,6 @@ public class PersonNameParser {
cleaned = cleaned.substring(0, parenMatcher.start()).trim();
}
// 3. If no multi-separator present, this is a single person
if (!MULTI_SEPARATOR.matcher(cleaned).find()) {
return List.of(cleaned);
}
// 4. Split on " und " / " u "
String[] parts = MULTI_SEPARATOR.split(cleaned);
@@ -112,35 +122,157 @@ public class PersonNameParser {
return nameParts;
}
// --- Pipeline result records (package-private for testing) ---
public record MaidenNameResult(String cleaned, String maidenName) {}
public record AnnotationResult(String cleaned, String annotation) {}
public record TitleResult(String cleaned, String title) {}
record NameParts(String firstName, String lastName) {}
/**
* Splits a single full name string into firstName and lastName.
* Uses known last names first; falls back to splitting on the last space.
* Splits a single full name string into a structured SplitName.
* Pipeline: stripMaidenName → normalizeDotCompressed → stripAnnotation → stripTitle → splitByKnownLastNameOrFallback
*/
public static SplitName split(String rawName) {
if (rawName == null || rawName.isBlank()) {
return new SplitName("?", "?");
return new SplitName(null, "?", "?", null, null);
}
String cleaned = GEB_PATTERN.matcher(rawName).replaceAll("").trim();
MaidenNameResult maiden = stripMaidenName(rawName);
String cleaned = maiden.cleaned();
// Normalize dot-compressed names: "Dr.Fr.Zarncke" -> "Dr. Fr. Zarncke"
if (!cleaned.contains(" ") && cleaned.contains(".")) {
cleaned = cleaned.replace(".", ". ").trim();
cleaned = normalizeDotCompressed(cleaned);
AnnotationResult paren = stripAnnotation(cleaned);
cleaned = paren.cleaned();
TitleResult title = stripTitle(cleaned);
cleaned = title.cleaned();
NameParts parts = splitByKnownLastNameOrFallback(cleaned);
String firstName = parts.firstName();
String lastName = parts.lastName();
// When a title was stripped and no first name could be extracted, the
// remaining text is the lastName. "Tante Molly" -> title=Tante, lastName=Molly.
if (title.title() != null) {
if ("?".equals(lastName) && !cleaned.contains(" ")) {
lastName = firstName;
firstName = null;
} else if (Objects.equals(firstName, lastName)) {
firstName = null;
}
}
return new SplitName(
title.title(), firstName, lastName,
maiden.maidenName(), paren.annotation()
);
}
/** Strips geb annotations and extracts the maiden name. */
public static MaidenNameResult stripMaidenName(String input) {
Matcher m = GEB_PATTERN.matcher(input);
if (m.find()) {
String cleaned = input.substring(0, m.start()).trim();
String maidenName = m.group(1).trim();
return new MaidenNameResult(cleaned, maidenName);
}
return new MaidenNameResult(input, null);
}
/** Normalizes dot-compressed names: "Dr.Fr.Zarncke" → "Dr. Fr. Zarncke" */
static String normalizeDotCompressed(String input) {
if (!input.contains(" ") && input.contains(".")) {
return input.replace(".", ". ").trim();
}
return input;
}
private static final Pattern PAREN_ANNOTATION = Pattern.compile("\\s*\\(([^)]*)\\)\\s*$");
private static final Pattern UNCERTAIN_NAME = Pattern.compile("^(\\S+)\\s+\\?\\s*$");
/** Strips trailing parenthesized annotations and extracts the content. */
public static AnnotationResult stripAnnotation(String input) {
Matcher m = PAREN_ANNOTATION.matcher(input);
if (!m.find()) {
return new AnnotationResult(input, null);
}
String cleaned = input.substring(0, m.start()).trim();
String rawAnnotation = m.group(1).trim();
Matcher uncertainMatcher = UNCERTAIN_NAME.matcher(rawAnnotation);
if (uncertainMatcher.matches()) {
String nameFromAnnotation = uncertainMatcher.group(1);
cleaned = (cleaned + " " + nameFromAnnotation).trim();
return new AnnotationResult(cleaned, "?");
}
return new AnnotationResult(cleaned, rawAnnotation);
}
private static final List<String> DOT_PREFIXES = List.of("Dr.", "Prof.");
private static final List<String> WORD_PREFIXES = List.of(
"Frau", "Herr", "Freifrau", "Freiherr",
"Tante", "Onkel", "Schwester", "Bruder",
"Cousine", "Cousin", "Freundin", "Freund",
"Mutter", "Vater", "Pastor");
/** Strips known title/relationship prefixes, looping for stacked titles. */
public static TitleResult stripTitle(String input) {
String remaining = input;
StringBuilder titleBuilder = new StringBuilder();
boolean found = true;
while (found) {
found = false;
for (String prefix : DOT_PREFIXES) {
if (remaining.toLowerCase().startsWith(prefix.toLowerCase())) {
titleBuilder.append(titleBuilder.isEmpty() ? "" : " ").append(prefix);
remaining = remaining.substring(prefix.length()).trim();
found = true;
break;
}
}
if (found) continue;
for (String prefix : WORD_PREFIXES) {
String lower = remaining.toLowerCase();
if (lower.startsWith(prefix.toLowerCase() + " ") || lower.equals(prefix.toLowerCase())) {
titleBuilder.append(titleBuilder.isEmpty() ? "" : " ").append(prefix);
remaining = remaining.length() > prefix.length()
? remaining.substring(prefix.length() + 1).trim()
: "";
found = true;
break;
}
}
}
if (titleBuilder.isEmpty()) {
return new TitleResult(input, null);
}
return new TitleResult(remaining, titleBuilder.toString());
}
/** Splits a cleaned name into firstName/lastName using known last names or last-space fallback. */
static NameParts splitByKnownLastNameOrFallback(String cleaned) {
String lastName = findKnownLastName(cleaned);
if (lastName != null) {
String firstName = cleaned.substring(0, cleaned.length() - lastName.length()).trim();
if (firstName.isBlank()) firstName = cleaned;
return new SplitName(firstName, lastName);
return new NameParts(firstName, lastName);
}
int lastSpace = cleaned.lastIndexOf(' ');
if (lastSpace > 0) {
return new SplitName(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim());
return new NameParts(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim());
}
return new SplitName(cleaned, "?");
return new NameParts(cleaned, "?");
}
/** Returns the known last name that the given string ends with, or null. */

View File

@@ -1,9 +1,12 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.springframework.lang.Nullable;
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
@@ -11,6 +14,8 @@ 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.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.HttpStatus;
@@ -57,16 +62,38 @@ public class PersonService {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}
@Nullable
@Transactional
public Person findOrCreateByAlias(String rawName) {
String alias = rawName.trim();
PersonType type = PersonTypeClassifier.classify(alias);
if (type == PersonType.SKIP) return null;
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
return personRepository.save(Person.builder()
.alias(alias)
.lastName(alias)
.personType(type)
.build());
}
PersonNameParser.SplitName split = PersonNameParser.split(alias);
return personRepository.save(Person.builder()
Person person = personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
if (split.maidenName() != null) {
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
aliasRepository.save(PersonNameAlias.builder()
.person(person)
.lastName(split.maidenName())
.type(PersonNameAliasType.MAIDEN_NAME)
.sortOrder(nextSortOrder)
.build());
}
return person;
});
}
@@ -84,6 +111,7 @@ public class PersonService {
public Person createPerson(PersonUpdateDTO dto) {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
@@ -111,6 +139,7 @@ public class PersonService {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());

View File

@@ -0,0 +1,63 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import org.raddatz.familienarchiv.model.PersonType;
public class PersonTypeClassifier {
private static final List<String> SKIP_KEYWORDS = List.of(
"Briefumschlag", "Kondolenzbriefe", "Hochzeitsgedicht");
private static final List<String> INSTITUTION_START = List.of(
"Firma", "Architekt");
private static final List<String> INSTITUTION_END = List.of(
"GmbH");
private static final List<String> GROUP_START = List.of(
"Familie", "Comité", "Comite", "Geschwister", "Gesellschafter",
"Garde", "Mitarbeiter");
private static final List<String> GROUP_CONTAINS = List.of(
"Eltern", "Kinder", "Schwiegereltern");
public static PersonType classify(String rawName) {
if (rawName == null || rawName.isBlank()) return PersonType.PERSON;
String lower = rawName.trim().toLowerCase();
for (String keyword : SKIP_KEYWORDS) {
if (lower.startsWith(keyword.toLowerCase())) return PersonType.SKIP;
}
for (String keyword : INSTITUTION_START) {
if (lower.startsWith(keyword.toLowerCase())) return PersonType.INSTITUTION;
}
for (String keyword : INSTITUTION_END) {
if (lower.endsWith(keyword.toLowerCase())) return PersonType.INSTITUTION;
}
if (lower.endsWith(" co") || lower.endsWith(" co.")) return PersonType.INSTITUTION;
for (String keyword : GROUP_START) {
if (lower.startsWith(keyword.toLowerCase())) return PersonType.GROUP;
}
for (String keyword : GROUP_CONTAINS) {
if (containsWord(lower, keyword.toLowerCase())) return PersonType.GROUP;
}
return PersonType.PERSON;
}
private static boolean containsWord(String text, String word) {
int fromIndex = 0;
while (true) {
int idx = text.indexOf(word, fromIndex);
if (idx < 0) return false;
boolean startOk = idx == 0 || !Character.isLetter(text.charAt(idx - 1));
int end = idx + word.length();
boolean endOk = end >= text.length() || !Character.isLetter(text.charAt(end));
if (startOk && endOk) return true;
fromIndex = idx + 1;
}
}
}

View File

@@ -0,0 +1,12 @@
-- Add title column for honorifics/salutations (Dr., Tante, Frau, etc.)
ALTER TABLE persons ADD COLUMN title VARCHAR(50);
-- Add person_type column to distinguish persons from institutions/groups.
-- SKIP is intentionally omitted: it exists in the Java enum for parse-time
-- filtering but must never be persisted. The CHECK constraint enforces this.
ALTER TABLE persons ADD COLUMN person_type VARCHAR(20) NOT NULL DEFAULT 'PERSON';
ALTER TABLE persons ADD CONSTRAINT chk_person_type
CHECK (person_type IN ('PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'));
-- Make first_name nullable for non-person entities (institutions, groups)
ALTER TABLE persons ALTER COLUMN first_name DROP NOT NULL;

View File

@@ -73,8 +73,10 @@ class PersonControllerTest {
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
return new PersonSummaryDTO() {
public java.util.UUID getId() { return UUID.randomUUID(); }
public String getTitle() { return null; }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public String getPersonType() { return "PERSON"; }
public String getAlias() { return null; }
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }

View File

@@ -0,0 +1,53 @@
package org.raddatz.familienarchiv.model;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PersonTest {
@Test
void getDisplayName_withAllFields() {
Person person = Person.builder()
.title("Dr.")
.firstName("Walter")
.lastName("de Gruyter")
.build();
assertThat(person.getDisplayName()).isEqualTo("Dr. Walter de Gruyter");
}
@Test
void getDisplayName_withoutTitle() {
Person person = Person.builder()
.firstName("Clara")
.lastName("Cram")
.build();
assertThat(person.getDisplayName()).isEqualTo("Clara Cram");
}
@Test
void getDisplayName_withNullFirstName() {
Person person = Person.builder()
.lastName("Gesellschafter des Verlages")
.build();
assertThat(person.getDisplayName()).isEqualTo("Gesellschafter des Verlages");
}
@Test
void getDisplayName_withTitleAndNullFirstName() {
Person person = Person.builder()
.title("Tante")
.lastName("Molly")
.build();
assertThat(person.getDisplayName()).isEqualTo("Tante Molly");
}
@Test
void personType_defaultsToPerson() {
Person person = Person.builder()
.firstName("Clara")
.lastName("Cram")
.build();
assertThat(person.getPersonType()).isEqualTo(PersonType.PERSON);
}
}

View File

@@ -440,4 +440,26 @@ class PersonRepositoryTest {
assertThat(results).hasSize(1);
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
}
// ─── null firstName handling ────────────────────────────────────────────
@Test
void searchByName_findsPersonWithNullFirstName() {
personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());
List<Person> result = personRepository.searchByName("Gesellschafter");
assertThat(result).hasSize(1);
assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
}
@Test
void searchWithDocumentCount_findsPersonWithNullFirstName() {
personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Gesellschafter");
assertThat(result).hasSize(1);
assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
}
}

View File

@@ -1,6 +1,8 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import java.util.List;
@@ -22,6 +24,30 @@ class PersonNameParserTest {
.containsExactly("Eugenie de Gruyter");
}
@Test
void singlePerson_annotationParenPreserved() {
assertThat(PersonNameParser.parseReceivers("Clara de Gruyter(*1871)"))
.containsExactly("Clara de Gruyter(*1871)");
}
@Test
void singlePerson_nicknameParenPreserved() {
assertThat(PersonNameParser.parseReceivers("Gertrud D.(Tuttu)"))
.containsExactly("Gertrud D.(Tuttu)");
}
@Test
void gebAnnotation_noDot_multiWord_stripped() {
assertThat(PersonNameParser.parseReceivers("Ella Dieckmann, geb de Gruyter"))
.containsExactly("Ella Dieckmann");
}
@Test
void gebAnnotation_noDot_singleWord_stripped() {
assertThat(PersonNameParser.parseReceivers("Elise Rockstroh geb Sintenis"))
.containsExactly("Elise Rockstroh");
}
@Test
void twoFirstNames_sharedKnownLastName_und() {
assertThat(PersonNameParser.parseReceivers("Walter und Eugenie de Gruyter"))
@@ -81,6 +107,9 @@ class PersonNameParserTest {
PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter");
assertThat(result.firstName()).isEqualTo("Walter");
assertThat(result.lastName()).isEqualTo("de Gruyter");
assertThat(result.title()).isNull();
assertThat(result.maidenName()).isNull();
assertThat(result.annotation()).isNull();
}
@Test
@@ -107,22 +136,31 @@ class PersonNameParserTest {
@Test
void split_gebAnnotation_stripped() {
PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Müller");
assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("Eugenie");
assertThat(result.lastName()).isEqualTo("de Gruyter");
assertThat(result.maidenName()).isEqualTo("Müller");
assertThat(result.annotation()).isNull();
}
@Test
void split_null_returnsPlaceholder() {
PersonNameParser.SplitName result = PersonNameParser.split(null);
assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("?");
assertThat(result.lastName()).isEqualTo("?");
assertThat(result.maidenName()).isNull();
assertThat(result.annotation()).isNull();
}
@Test
void split_blank_returnsPlaceholder() {
PersonNameParser.SplitName result = PersonNameParser.split(" ");
assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("?");
assertThat(result.lastName()).isEqualTo("?");
assertThat(result.maidenName()).isNull();
assertThat(result.annotation()).isNull();
}
@Test
@@ -152,14 +190,16 @@ class PersonNameParserTest {
@Test
void split_dotCompressed_titleFirstNameLastName() {
PersonNameParser.SplitName result = PersonNameParser.split("Dr.Fr.Zarncke");
assertThat(result.firstName()).isEqualTo("Dr. Fr.");
assertThat(result.title()).isEqualTo("Dr.");
assertThat(result.firstName()).isEqualTo("Fr.");
assertThat(result.lastName()).isEqualTo("Zarncke");
}
@Test
void split_dotCompressed_titleAndLastName() {
PersonNameParser.SplitName result = PersonNameParser.split("Dr.Zarnke");
assertThat(result.firstName()).isEqualTo("Dr.");
assertThat(result.title()).isEqualTo("Dr.");
assertThat(result.firstName()).isNull();
assertThat(result.lastName()).isEqualTo("Zarnke");
}
@@ -172,7 +212,8 @@ class PersonNameParserTest {
@Test
void split_alreadySpacedDotName_noDoubleSpacing() {
PersonNameParser.SplitName result = PersonNameParser.split("Dr. Fr. Zarncke");
assertThat(result.firstName()).isEqualTo("Dr. Fr.");
assertThat(result.title()).isEqualTo("Dr.");
assertThat(result.firstName()).isEqualTo("Fr.");
assertThat(result.lastName()).isEqualTo("Zarncke");
}
@@ -244,4 +285,256 @@ class PersonNameParserTest {
List<String> result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
assertThat(result).containsExactlyInAnyOrder("Müller", "Herbert de Gruyter");
}
// --- pipeline pass-through methods ---
@Test
void stripMaidenName_isPassthrough() {
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Walter de Gruyter");
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
assertThat(result.maidenName()).isNull();
}
@Test
void stripAnnotation_noParens_returnsNull() {
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Walter de Gruyter");
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
assertThat(result.annotation()).isNull();
}
@Test
void stripAnnotation_birthYear_noSpace() {
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Clara de Gruyter(*1871)");
assertThat(result.cleaned()).isEqualTo("Clara de Gruyter");
assertThat(result.annotation()).isEqualTo("*1871");
}
@Test
void stripAnnotation_uncertainty_withSpace() {
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Ernst Kurmany (?)");
assertThat(result.cleaned()).isEqualTo("Ernst Kurmany");
assertThat(result.annotation()).isEqualTo("?");
}
@Test
void stripAnnotation_nickname_noSpace() {
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Gertrud D.(Tuttu)");
assertThat(result.cleaned()).isEqualTo("Gertrud D.");
assertThat(result.annotation()).isEqualTo("Tuttu");
}
@Test
void stripAnnotation_uncertainName_extractsNameBack() {
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Richard (Quast ? )");
assertThat(result.cleaned()).isEqualTo("Richard Quast");
assertThat(result.annotation()).isEqualTo("?");
}
@Test
void stripAnnotation_onlyParen_returnsPlaceholder() {
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("(OnlyParen)");
assertThat(result.cleaned()).isEmpty();
assertThat(result.annotation()).isEqualTo("OnlyParen");
}
// --- split — annotation extraction end-to-end ---
@Test
void split_birthYearAnnotation_extracted() {
PersonNameParser.SplitName result = PersonNameParser.split("Clara de Gruyter(*1871)");
assertThat(result.firstName()).isEqualTo("Clara");
assertThat(result.lastName()).isEqualTo("de Gruyter");
assertThat(result.annotation()).isEqualTo("*1871");
}
@Test
void split_uncertainName_extractsLastName() {
PersonNameParser.SplitName result = PersonNameParser.split("Richard (Quast ? )");
assertThat(result.firstName()).isEqualTo("Richard");
assertThat(result.lastName()).isEqualTo("Quast");
assertThat(result.annotation()).isEqualTo("?");
}
@Test
void stripTitle_noPrefix_returnsNull() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Walter de Gruyter");
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
assertThat(result.title()).isNull();
}
@Test
void stripTitle_tante() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Tante Molly");
assertThat(result.cleaned()).isEqualTo("Molly");
assertThat(result.title()).isEqualTo("Tante");
}
@Test
void stripTitle_schwester() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Schwester Hanni");
assertThat(result.cleaned()).isEqualTo("Hanni");
assertThat(result.title()).isEqualTo("Schwester");
}
@Test
void stripTitle_frau() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Frau Bakker");
assertThat(result.cleaned()).isEqualTo("Bakker");
assertThat(result.title()).isEqualTo("Frau");
}
@Test
void stripTitle_cousine_withFullName() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Cousine Emmy Haniel");
assertThat(result.cleaned()).isEqualTo("Emmy Haniel");
assertThat(result.title()).isEqualTo("Cousine");
}
@Test
void stripTitle_freifrau() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Freifrau von Massenbach");
assertThat(result.cleaned()).isEqualTo("von Massenbach");
assertThat(result.title()).isEqualTo("Freifrau");
}
@Test
void stripTitle_dotPrefix_withSpace() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Dr. Sattelmacher");
assertThat(result.cleaned()).isEqualTo("Sattelmacher");
assertThat(result.title()).isEqualTo("Dr.");
}
@Test
void stripTitle_dotPrefix_noSpace() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Dr.von Gelden");
assertThat(result.cleaned()).isEqualTo("von Gelden");
assertThat(result.title()).isEqualTo("Dr.");
}
@Test
void stripTitle_stacked_profDr() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Prof. Dr. Muller");
assertThat(result.cleaned()).isEqualTo("Muller");
assertThat(result.title()).isEqualTo("Prof. Dr.");
}
// --- split — title extraction end-to-end ---
@Test
void split_tante_setsTitle_firstNameNull() {
PersonNameParser.SplitName result = PersonNameParser.split("Tante Molly");
assertThat(result.title()).isEqualTo("Tante");
assertThat(result.firstName()).isNull();
assertThat(result.lastName()).isEqualTo("Molly");
}
@Test
void split_dotTitle_afterDotNorm() {
PersonNameParser.SplitName result = PersonNameParser.split("Dr.Fr.Zarncke");
assertThat(result.title()).isEqualTo("Dr.");
assertThat(result.firstName()).isEqualTo("Fr.");
assertThat(result.lastName()).isEqualTo("Zarncke");
}
@Test
void split_dotTitle_noSpace_vonLastName() {
PersonNameParser.SplitName result = PersonNameParser.split("Dr.von Gelden");
assertThat(result.title()).isEqualTo("Dr.");
assertThat(result.firstName()).isNull();
assertThat(result.lastName()).isEqualTo("von Gelden");
}
// --- regression: non-prefixes not stripped ---
@Test
void split_walter_noTitleStrip() {
PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter");
assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("Walter");
assertThat(result.lastName()).isEqualTo("de Gruyter");
}
@Test
void split_conrad_vonGeldern_noTitleStrip() {
PersonNameParser.SplitName result = PersonNameParser.split("Conrad von Geldern");
assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("Conrad");
assertThat(result.lastName()).isEqualTo("von Geldern");
}
// --- stripMaidenName — maiden name extraction ---
@Test
void stripMaidenName_standardDot_singleWord() {
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Eugenie de Gruyter geb. Muller");
assertThat(result.cleaned()).isEqualTo("Eugenie de Gruyter");
assertThat(result.maidenName()).isEqualTo("Muller");
}
@Test
void stripMaidenName_dot_multiWordMaidenName() {
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Clara Cram geb. de Gruyter");
assertThat(result.cleaned()).isEqualTo("Clara Cram");
assertThat(result.maidenName()).isEqualTo("de Gruyter");
}
@Test
void stripMaidenName_commaPrefix_noDot_multiWord() {
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Ella Dieckmann, geb de Gruyter");
assertThat(result.cleaned()).isEqualTo("Ella Dieckmann");
assertThat(result.maidenName()).isEqualTo("de Gruyter");
}
@Test
void stripMaidenName_noDot_singleWord() {
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Elise Rockstroh geb Sintenis");
assertThat(result.cleaned()).isEqualTo("Elise Rockstroh");
assertThat(result.maidenName()).isEqualTo("Sintenis");
}
@Test
void stripMaidenName_noDot_noMarriedLastName() {
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Elisabeth geb Fernow");
assertThat(result.cleaned()).isEqualTo("Elisabeth");
assertThat(result.maidenName()).isEqualTo("Fernow");
}
@Test
void stripMaidenName_noGeb_returnsNullMaidenName() {
PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Walter de Gruyter");
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
assertThat(result.maidenName()).isNull();
}
// --- split — maiden name extraction end-to-end ---
@Test
void split_gebDot_extractsMaidenName() {
PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Muller");
assertThat(result.firstName()).isEqualTo("Eugenie");
assertThat(result.lastName()).isEqualTo("de Gruyter");
assertThat(result.maidenName()).isEqualTo("Muller");
}
@Test
void split_gebNoDot_multiWordMaidenName() {
PersonNameParser.SplitName result = PersonNameParser.split("Clara Cram geb. de Gruyter");
assertThat(result.firstName()).isEqualTo("Clara");
assertThat(result.lastName()).isEqualTo("Cram");
assertThat(result.maidenName()).isEqualTo("de Gruyter");
}
// --- enum values ---
@Test
void personType_hasFiveValues() {
assertThat(PersonType.values()).containsExactly(
PersonType.PERSON, PersonType.INSTITUTION, PersonType.GROUP,
PersonType.UNKNOWN, PersonType.SKIP);
}
@Test
void personNameAliasType_includesMaidenName() {
assertThat(PersonNameAliasType.valueOf("MAIDEN_NAME")).isEqualTo(PersonNameAliasType.MAIDEN_NAME);
}
}

View File

@@ -0,0 +1,66 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class PersonServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired PersonService personService;
@Autowired PersonRepository personRepository;
@Test
void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
Person result = personService.findOrCreateByAlias("Briefumschlag aus Java");
assertThat(result).isNull();
assertThat(personRepository.findAll()).isEmpty();
}
@Test
void findOrCreateByAlias_institutionStoresFullNameInLastName() {
Person result = personService.findOrCreateByAlias("Arthur Collignon GmbH");
assertThat(result).isNotNull();
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
assertThat(result.getFirstName()).isNull();
assertThat(result.getLastName()).isEqualTo("Arthur Collignon GmbH");
assertThat(result.getDisplayName()).isEqualTo("Arthur Collignon GmbH");
}
@Test
void findOrCreateByAlias_groupStoresFullNameInLastName() {
Person result = personService.findOrCreateByAlias("Geschwister de Gruyter");
assertThat(result).isNotNull();
assertThat(result.getPersonType()).isEqualTo(PersonType.GROUP);
assertThat(result.getFirstName()).isNull();
assertThat(result.getLastName()).isEqualTo("Geschwister de Gruyter");
}
@Test
void findOrCreateByAlias_personSplitsNameNormally() {
Person result = personService.findOrCreateByAlias("Clara Cram");
assertThat(result).isNotNull();
assertThat(result.getPersonType()).isEqualTo(PersonType.PERSON);
assertThat(result.getFirstName()).isEqualTo("Clara");
assertThat(result.getLastName()).isEqualTo("Cram");
}
}

View File

@@ -12,6 +12,7 @@ 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.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.web.server.ResponseStatusException;
@@ -201,6 +202,75 @@ class PersonServiceTest {
verify(personRepository).save(any());
}
@Test
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
String alias = "Clara Cram geb. de Gruyter";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenReturn(saved);
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
personService.findOrCreateByAlias(alias);
verify(aliasRepository).save(argThat(a ->
a.getLastName().equals("de Gruyter") &&
a.getType() == PersonNameAliasType.MAIDEN_NAME));
}
@Test
void findOrCreateByAlias_returnsNull_whenSkip() {
Person result = personService.findOrCreateByAlias("Briefumschlag aus Java");
assertThat(result).isNull();
verify(personRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
String alias = "Arthur Collignon GmbH";
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> {
Person p = inv.getArgument(0);
p.setId(UUID.randomUUID());
return p;
});
Person result = personService.findOrCreateByAlias(alias);
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
assertThat(result.getFirstName()).isNull();
assertThat(result.getLastName()).isEqualTo("Arthur Collignon GmbH");
}
@Test
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
String alias = "Geschwister de Gruyter";
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> {
Person p = inv.getArgument(0);
p.setId(UUID.randomUUID());
return p;
});
Person result = personService.findOrCreateByAlias(alias);
assertThat(result.getPersonType()).isEqualTo(PersonType.GROUP);
assertThat(result.getFirstName()).isNull();
assertThat(result.getLastName()).isEqualTo("Geschwister de Gruyter");
}
@Test
void findOrCreateByAlias_noAlias_whenNoGeb() {
String alias = "Clara Cram";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenReturn(saved);
personService.findOrCreateByAlias(alias);
verify(aliasRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_trimsInput() {
String alias = " Clara Cram ";

View File

@@ -0,0 +1,94 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.raddatz.familienarchiv.model.PersonType;
import static org.assertj.core.api.Assertions.assertThat;
class PersonTypeClassifierTest {
// --- SKIP ---
@ParameterizedTest
@CsvSource({
"'Briefumschlag aus Java', SKIP",
"'Kondolenzbriefe zum Tod von Walter de Gruyter', SKIP",
"'Hochzeitsgedicht fur Paul u Luise de Gruyter', SKIP"
})
void classify_skipEntries(String input, PersonType expected) {
assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
}
// --- INSTITUTION ---
@ParameterizedTest
@CsvSource({
"'Arthur Collignon GmbH', INSTITUTION",
"'Firma Auschrath', INSTITUTION",
"'Westermann u Co', INSTITUTION",
"'Architekt Korschelt u Renker', INSTITUTION"
})
void classify_institutionEntries(String input, PersonType expected) {
assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
}
// --- GROUP ---
@ParameterizedTest
@CsvSource({
"'Comite der Abschiedsfeier', GROUP",
"'Comité zur Errichtung eines Heine-Denkmals', GROUP",
"'Garde du Corps', GROUP",
"'Geschwister de Gruyter', GROUP",
"'Gesellschafter des Verlages', GROUP",
"'Ella de Gruyters Eltern', GROUP",
"'Eugenie de Gruyters Kinder', GROUP",
"'Hilde de Gruyters Schwiegereltern', GROUP",
"'Eltern Muller', GROUP",
"'Familie Cram', GROUP",
"'Familie Hasenvlever', GROUP",
"'Mitarbeiter Verlag', GROUP",
"'Mitarbeiter Druckerei TrebbinClara Cram', GROUP",
"'Mitarbeiter Kunstverlag Mu', GROUP"
})
void classify_groupEntries(String input, PersonType expected) {
assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
}
// --- PERSON (default) ---
@ParameterizedTest
@CsvSource({
"'Walter de Gruyter', PERSON",
"'Clara Cram', PERSON",
"'Eugenie de Gruyter geb. Müller', PERSON",
"'Dr. Firma Mueller', PERSON"
})
void classify_personEntries(String input, PersonType expected) {
assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
}
// --- Edge cases ---
@Test
void classify_null_returnsPerson() {
assertThat(PersonTypeClassifier.classify(null)).isEqualTo(PersonType.PERSON);
}
@Test
void classify_blank_returnsPerson() {
assertThat(PersonTypeClassifier.classify(" ")).isEqualTo(PersonType.PERSON);
}
@Test
void classify_caseInsensitive() {
assertThat(PersonTypeClassifier.classify("firma auschrath")).isEqualTo(PersonType.INSTITUTION);
}
@Test
void classify_containsWord_findsSecondOccurrence() {
assertThat(PersonTypeClassifier.classify("Nachbareltern Eltern")).isEqualTo(PersonType.GROUP);
}
}

View File

@@ -485,7 +485,12 @@
"person_alias_type_BIRTH": "geborene/r",
"person_alias_type_WIDOWED": "verwitwete/r",
"person_alias_type_DIVORCED": "geschiedene/r",
"person_alias_type_MAIDEN_NAME": "Geburtsname",
"person_alias_type_OTHER": "Sonstiger Name",
"person_type_PERSON": "Person",
"person_type_INSTITUTION": "Institution",
"person_type_GROUP": "Gruppe",
"person_type_UNKNOWN": "Unbekannt",
"person_alias_add_heading": "Name hinzufuegen",
"person_alias_label_type": "Art",
"person_alias_label_last_name": "Nachname",

View File

@@ -485,7 +485,12 @@
"person_alias_type_BIRTH": "Birth name",
"person_alias_type_WIDOWED": "Name as widow/widower",
"person_alias_type_DIVORCED": "Name after divorce",
"person_alias_type_MAIDEN_NAME": "Maiden name",
"person_alias_type_OTHER": "Other name",
"person_type_PERSON": "Person",
"person_type_INSTITUTION": "Institution",
"person_type_GROUP": "Group",
"person_type_UNKNOWN": "Unknown",
"person_alias_add_heading": "Add name",
"person_alias_label_type": "Type",
"person_alias_label_last_name": "Last name",

View File

@@ -485,7 +485,12 @@
"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_MAIDEN_NAME": "Apellido de soltera",
"person_alias_type_OTHER": "Otro nombre",
"person_type_PERSON": "Persona",
"person_type_INSTITUTION": "Institución",
"person_type_GROUP": "Grupo",
"person_type_UNKNOWN": "Desconocido",
"person_alias_add_heading": "Agregar nombre",
"person_alias_label_type": "Tipo",
"person_alias_label_last_name": "Apellido",

View File

@@ -7,7 +7,7 @@ type Document = {
id: string;
title: string;
updatedAt?: string;
sender?: { id: string; firstName: string; lastName: string };
sender?: { id: string; firstName?: string | null; lastName: string; displayName: string };
};
type StatsDTO = components['schemas']['StatsDTO'];

View File

@@ -2,9 +2,9 @@
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { personAvatarColor } from '$lib/utils/personFormat';
import { getInitials as calcInitials, personAvatarColor } from '$lib/utils/personFormat';
type Person = { id: string; firstName: string; lastName: string };
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type Props = {
@@ -33,11 +33,11 @@ let showAllReceivers = $state(false);
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
function getInitials(person: Person): string {
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
return calcInitials(person);
}
function getFullName(person: Person): string {
return `${person.firstName} ${person.lastName}`;
return person.displayName;
}
</script>

View File

@@ -5,10 +5,10 @@ import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
afterEach(cleanup);
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' };
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller', displayName: 'Karl Müller' };
const receivers = [
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt' },
{ id: 'r2', firstName: 'Hans', lastName: 'Weber' }
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
{ id: 'r2', firstName: 'Hans', lastName: 'Weber', displayName: 'Hans Weber' }
];
const tags = [
{ id: 't1', name: 'Familienbrief' },

View File

@@ -7,7 +7,7 @@ import PersonChipRow from './PersonChipRow.svelte';
import OverflowPillButton from './OverflowPillButton.svelte';
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
type Person = { id: string; firstName: string; lastName: string };
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type Doc = {

View File

@@ -3,7 +3,7 @@ import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
type Person = { id: string; firstName: string; lastName: string };
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Props = {
extraCount: number;
@@ -67,8 +67,7 @@ function handleKeydown(e: KeyboardEvent) {
href="/persons/{person.id}"
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
>
{person.firstName}
{person.lastName}
{person.displayName}
</a>
</div>
{/each}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { abbreviateName, personAvatarColor } from '$lib/utils/personFormat';
import { abbreviateName, getInitials, personAvatarColor } from '$lib/utils/personFormat';
type Person = { id: string; firstName: string; lastName: string };
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Props = {
person: Person;
@@ -10,13 +10,9 @@ type Props = {
let { person, abbreviated }: Props = $props();
const displayName = $derived(
abbreviated ? abbreviateName(person) : `${person.firstName} ${person.lastName}`
);
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
const avatarColor = $derived(personAvatarColor(person.id));
const initials = $derived(
`${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase()
);
const initials = $derived(getInitials(person));
</script>
<a
@@ -30,5 +26,5 @@ const initials = $derived(
>
{initials}
</span>
<span class="text-[14px] font-semibold text-ink">{displayName}</span>
<span class="text-[14px] font-semibold text-ink">{name}</span>
</a>

View File

@@ -2,7 +2,7 @@
import PersonChip from './PersonChip.svelte';
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
type Person = { id: string; firstName: string; lastName: string };
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Props = {
sender: Person | null | undefined;

View File

@@ -73,8 +73,7 @@ function removePerson(id: string | undefined) {
<span
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
>
{person.firstName}
{person.lastName}
{person.displayName}
<button
type="button"
onclick={() => removePerson(person.id)}
@@ -121,7 +120,7 @@ function removePerson(id: string | undefined) {
role="button"
tabindex="0"
>
{person.lastName}, {person.firstName}
{person.displayName}
</div>
{/each}
{/if}

View File

@@ -7,9 +7,21 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
{ id: '3', firstName: 'Karl', lastName: 'König' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
},
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
];
function mockFetch(persons = PERSONS) {
@@ -45,8 +57,20 @@ describe('PersonMultiSelect rendering', () => {
it('renders pre-selected persons as chips', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]
});
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
@@ -57,8 +81,20 @@ describe('PersonMultiSelect rendering', () => {
it('renders hidden inputs for each selected person', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]
});
await tick();
@@ -70,7 +106,15 @@ describe('PersonMultiSelect rendering', () => {
it('hides the placeholder when persons are selected', async () => {
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]
});
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
});
@@ -85,7 +129,7 @@ describe('PersonMultiSelect selecting persons', () => {
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await page.getByText('Max Mustermann').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({
@@ -100,11 +144,11 @@ describe('PersonMultiSelect selecting persons', () => {
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await page.getByText('Max Mustermann').click();
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Musterfrau, Anna').click();
await page.getByText('Anna Musterfrau').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
@@ -116,22 +160,41 @@ describe('PersonMultiSelect selecting persons', () => {
it('filters already-selected persons from search results', async () => {
mockFetch();
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]
});
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
// Chip still shows "Max Mustermann" but the dropdown item (role=button) must be filtered out
await expect
.element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Anna Musterfrau' })).toBeInTheDocument();
});
it('selects a result with Enter key', async () => {
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
mockFetch([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Ma');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await page.getByText('Max Mustermann').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
});
@@ -142,8 +205,20 @@ describe('PersonMultiSelect removing persons', () => {
it('removes a chip when its × button is clicked', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]
});
// Buttons have aria-label="Entfernen"
@@ -156,8 +231,20 @@ describe('PersonMultiSelect removing persons', () => {
it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]
});
await page.getByRole('button', { name: 'Entfernen' }).first().click();
@@ -177,9 +264,9 @@ describe('PersonMultiSelect click outside', () => {
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
personType: string;
}
let { personType }: Props = $props();
const config = $derived.by(() => {
switch (personType) {
case 'INSTITUTION':
return { label: m.person_type_INSTITUTION(), icon: 'building' as const };
case 'GROUP':
return { label: m.person_type_GROUP(), icon: 'people' as const };
case 'UNKNOWN':
return { label: m.person_type_UNKNOWN(), icon: 'question' as const };
default:
return null;
}
});
</script>
{#if config}
<span
class="badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
class:badge-institution={personType === 'INSTITUTION'}
class:badge-group={personType === 'GROUP'}
class:badge-unknown={personType === 'UNKNOWN'}
>
{#if config.icon === 'building'}
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
/>
</svg>
{:else if config.icon === 'people'}
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
{:else}
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
/>
</svg>
{/if}
{config.label}
</span>
{/if}
<style>
.badge-institution {
background-color: var(--c-badge-institution-bg);
color: var(--c-badge-institution-text);
border-color: var(--c-badge-institution-border);
}
.badge-group {
background-color: var(--c-badge-group-bg);
color: var(--c-badge-group-text);
border-color: var(--c-badge-group-border);
}
.badge-unknown {
background-color: var(--c-badge-unknown-bg);
color: var(--c-badge-unknown-text);
border-color: var(--c-badge-unknown-border);
}
</style>

View File

@@ -117,7 +117,7 @@ function handleFocus() {
function selectPerson(person: Person) {
value = person.id!;
searchTerm = `${person.firstName} ${person.lastName}`;
searchTerm = person.displayName;
showDropdown = false;
onchange?.(person.id!);
}
@@ -166,7 +166,7 @@ function selectPerson(person: Person) {
>
<div class="flex items-center">
<span class="block truncate font-medium">
{person.lastName}, {person.firstName}
{person.displayName}
</span>
</div>
</div>

View File

@@ -7,8 +7,20 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
];
function mockFetchWithPersons(persons = PERSONS) {
@@ -76,8 +88,8 @@ describe('PersonTypeahead search', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
});
@@ -105,7 +117,7 @@ describe('PersonTypeahead search', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});
@@ -122,7 +134,7 @@ describe('PersonTypeahead selection', () => {
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
.element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
@@ -152,7 +164,15 @@ describe('PersonTypeahead selection', () => {
});
it('selects a result with Enter key', async () => {
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
mockFetchWithPersons([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
@@ -218,7 +238,7 @@ describe('PersonTypeahead correspondent mode', () => {
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
it('uses correspondents endpoint with q param when typing', async () => {
@@ -259,9 +279,9 @@ describe('PersonTypeahead click outside', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});

View File

@@ -952,14 +952,18 @@ export interface components {
Person: {
/** Format: uuid */
id: string;
firstName: string;
title?: string;
firstName?: string;
lastName: string;
/** @enum {string} */
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
readonly displayName: string;
};
DocumentUpdateDTO: {
title?: string;
@@ -1047,19 +1051,18 @@ export interface components {
newPassword?: string;
};
PersonNameAliasDTO: {
lastName?: string;
lastName: string;
firstName?: string;
/** @enum {string} */
type?: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "MAIDEN_NAME" | "OTHER";
};
PersonNameAlias: {
/** Format: uuid */
id: string;
person?: components["schemas"]["Person"];
lastName: string;
firstName?: string;
/** @enum {string} */
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "MAIDEN_NAME" | "OTHER";
/** Format: int32 */
sortOrder: number;
/** Format: date-time */
@@ -1203,8 +1206,10 @@ export interface components {
totalDocuments?: number;
};
PersonSummaryDTO: {
title?: string;
/** Format: uuid */
id?: string;
displayName?: string;
firstName?: string;
lastName?: string;
/** Format: int32 */
@@ -1213,6 +1218,7 @@ export interface components {
deathYear?: number;
alias?: string;
notes?: string;
personType?: string;
/** Format: int64 */
documentCount?: number;
};

View File

@@ -1,6 +1,6 @@
import { formatDocumentStatus } from './documentStatusLabel';
type Person = { firstName: string; lastName: string };
type Person = { firstName?: string | null; lastName: string; displayName: string };
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
type DocForMeta = {
sender?: Person | null;
@@ -18,7 +18,13 @@ function djb2(str: string): number {
return Math.abs(hash);
}
export function getInitials(person: Person): string {
if (person.firstName) return `${person.firstName[0]}${person.lastName[0]}`.toUpperCase();
return person.lastName.substring(0, 2).toUpperCase();
}
export function abbreviateName(person: Person): string {
if (!person.firstName) return person.lastName;
const first = person.firstName.trim();
const last = person.lastName.trim();
if (!last) return first;
@@ -26,6 +32,7 @@ export function abbreviateName(person: Person): string {
}
function abbreviateCompact(person: Person): string {
if (!person.firstName) return person.lastName;
const first = person.firstName.trim();
const last = person.lastName.trim();
if (!last) return first;

View File

@@ -93,8 +93,8 @@ export async function load({ url, fetch }) {
incompleteDocs,
recentDocs,
initialValues: {
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
senderName: senderObj?.displayName ?? '',
receiverName: receiverObj?.displayName ?? ''
},
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ },
error: null as string | null

View File

@@ -16,8 +16,8 @@ let {
originalFilename: string;
documentDate?: string | null;
location?: string | null;
sender?: { firstName: string; lastName: string } | null;
receivers?: { firstName: string; lastName: string }[];
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
tags?: { id: string; name: string }[];
}[];
canWrite: boolean;
@@ -102,7 +102,7 @@ let {
>{m.docs_list_from()}</span
>
{#if doc.sender}
<span class="text-ink">{doc.sender.firstName} {doc.sender.lastName}</span>
<span class="text-ink">{doc.sender.displayName}</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
@@ -114,7 +114,7 @@ let {
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-ink">
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
{doc.receivers.map((p) => p.displayName).join(', ')}
</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>

View File

@@ -52,8 +52,8 @@ export async function load({ url, fetch, locals }) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const p = result.data as { firstName: string; lastName: string } | undefined;
if (p) senderName = `${p.firstName} ${p.lastName}`;
const p = result.data as { displayName: string } | undefined;
if (p) senderName = p.displayName;
})
);
}
@@ -65,8 +65,8 @@ export async function load({ url, fetch, locals }) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const p = result.data as { firstName: string; lastName: string } | undefined;
if (p) receiverName = `${p.firstName} ${p.lastName}`;
const p = result.data as { displayName: string } | undefined;
if (p) receiverName = p.displayName;
})
);
}

View File

@@ -10,8 +10,13 @@ interface Props {
documentDate?: string;
location?: string;
status: string;
sender?: { id: string; firstName: string; lastName: string } | null;
receivers?: { id: string; firstName: string; lastName: string }[];
sender?: {
id: string;
firstName?: string | null;
lastName: string;
displayName: string;
} | null;
receivers?: { id: string; firstName?: string | null; lastName: string; displayName: string }[];
}[];
senderId: string;
receiverId?: string;
@@ -67,9 +72,9 @@ function statusDotClass(status: string): string {
function otherPartyName(doc: (typeof documents)[number]): string {
if (doc.sender?.id === senderId) {
const r = doc.receivers?.[0];
return r ? `${r.firstName} ${r.lastName}` : m.conv_no_party();
return r ? r.displayName : m.conv_no_party();
}
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : m.conv_no_party();
return doc.sender ? doc.sender.displayName : m.conv_no_party();
}
const newDocUrl = $derived(

View File

@@ -4,8 +4,9 @@ import { clickOutside } from '$lib/actions/clickOutside';
interface Correspondent {
id: string;
firstName: string;
firstName?: string | null;
lastName: string;
displayName: string;
}
interface Props {
@@ -41,7 +42,9 @@ function handleKeydown(event: KeyboardEvent, container: HTMLElement) {
}
function getInitials(person: Correspondent): string {
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
if (person.firstName)
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
return person.lastName.substring(0, 2).toUpperCase();
}
</script>
@@ -78,7 +81,7 @@ function getInitials(person: Correspondent): string {
{getInitials(person)}
</span>
<!-- Svelte auto-escapes — do not use {@html} here. -->
{person.lastName}, {person.firstName}
{person.displayName}
</div>
{/each}
{/if}

View File

@@ -54,7 +54,15 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
const docs = [{ id: 'd1', title: 'Testbrief' }];
const GET = mockApi([
{ ok: true, data: docs },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]);
const result = await load({
@@ -76,8 +84,24 @@ describe('korrespondenz load — senderId and receiverId set', () => {
it('calls conversation, sender person, and receiver person endpoints', async () => {
const GET = mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } },
{ ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
},
{
ok: true,
data: {
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON'
}
}
]);
const result = await load({
@@ -98,7 +122,15 @@ describe('korrespondenz load — canWrite', () => {
it('derives canWrite true from WRITE_ALL permission', async () => {
mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]);
const result = await load({
@@ -113,7 +145,15 @@ describe('korrespondenz load — canWrite', () => {
it('derives canWrite false when user lacks WRITE_ALL', async () => {
mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]);
const result = await load({
@@ -132,7 +172,15 @@ describe('korrespondenz load — backend error', () => {
it('throws when the conversation endpoint returns non-ok', async () => {
mockApi([
{ ok: false, status: 500 },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]);
await expect(

View File

@@ -39,8 +39,8 @@ export async function load({ url, fetch }) {
if (senderId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
const p = data as { firstName: string; lastName: string } | undefined;
if (p) senderName = `${p.firstName} ${p.lastName}`;
const p = data as { displayName: string } | undefined;
if (p) senderName = p.displayName;
})
);
}
@@ -48,8 +48,8 @@ export async function load({ url, fetch }) {
if (receiverId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
const p = data as { firstName: string; lastName: string } | undefined;
if (p) receiverName = `${p.firstName} ${p.lastName}`;
const p = data as { displayName: string } | undefined;
if (p) receiverName = p.displayName;
})
);
}

View File

@@ -15,7 +15,12 @@ let {
documentDate?: string;
location?: string;
status: string;
sender?: { id: string; firstName: string; lastName: string } | null;
sender?: {
id: string;
firstName?: string | null;
lastName: string;
displayName: string;
} | null;
}[];
senderId: string;
receiverId: string;
@@ -106,7 +111,7 @@ const enrichedDocuments = $derived(
: 'border-line bg-surface text-ink'}"
>
{#if doc.sender}
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
{doc.sender.firstName ? doc.sender.firstName[0] : doc.sender.lastName[0]}{doc.sender.lastName[0]}
{:else}
?
{/if}

View File

@@ -54,7 +54,7 @@ let selectedReceivers = $state(doc.receivers ?? []);
bind:selectedReceivers={selectedReceivers}
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
initialSenderName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
initialSenderName={doc.sender ? doc.sender.displayName : ''}
/>
<DescriptionSection
bind:tags={tags}

View File

@@ -24,14 +24,19 @@ export async function load({
const api = createApiClient(fetch);
let initialSenderName = '';
let initialReceivers: { id: string; firstName: string; lastName: string }[] = [];
let initialReceivers: {
id: string;
firstName?: string;
lastName: string;
displayName: string;
}[] = [];
const requests: Promise<void>[] = [];
if (senderId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
if (data) initialSenderName = `${data.firstName} ${data.lastName}`;
if (data) initialSenderName = data.displayName;
})
);
}
@@ -40,7 +45,14 @@ export async function load({
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
if (data)
initialReceivers = [{ id: data.id!, firstName: data.firstName, lastName: data.lastName }];
initialReceivers = [
{
id: data.id!,
firstName: data.firstName,
lastName: data.lastName,
displayName: data.displayName
}
];
})
);
}

View File

@@ -12,9 +12,8 @@ let { data, form } = $props();
let tags: string[] = $state([]);
let senderId = $state(untrack(() => data.initialSenderId));
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
untrack(() => data.initialReceivers)
);
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
$state(untrack(() => data.initialReceivers));
let parsedSuggestion = $state<FilenameParseResult>({});

View File

@@ -58,7 +58,9 @@ describe('New document page receiver prefill', () => {
it('shows a receiver chip when initialReceivers has a person', async () => {
const data = {
...baseData,
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
initialReceivers: [
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
]
};
render(Page, { data, form: null });
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
@@ -67,7 +69,9 @@ describe('New document page receiver prefill', () => {
it('renders a hidden receiverIds input for the prefilled receiver', async () => {
const data = {
...baseData,
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
initialReceivers: [
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
]
};
render(Page, { data, form: null });
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');

View File

@@ -120,7 +120,7 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
initialSenderName={doc.sender
? `${doc.sender.firstName} ${doc.sender.lastName}`
? doc.sender.displayName
: ''}
/>
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />

View File

@@ -106,6 +106,19 @@
--c-pdf-bg: #ebebeb;
--c-pdf-ctrl: #d8d8d8;
--c-pdf-text: #333333;
/* PersonType badge — institution (navy-tinted blue) */
--c-badge-institution-bg: #e8eff7;
--c-badge-institution-text: #1a4971;
--c-badge-institution-border: #c4d5e8;
/* PersonType badge — group (muted purple) */
--c-badge-group-bg: #f0e8f5;
--c-badge-group-text: #5a2d6f;
--c-badge-group-border: #d8c5e3;
/* PersonType badge — unknown (amber warning) */
--c-badge-unknown-bg: #fdf4e3;
--c-badge-unknown-text: #7a5a0a;
--c-badge-unknown-border: #f0ddb3;
}
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
@@ -148,6 +161,16 @@
--c-pdf-bg: #010e1e;
--c-pdf-ctrl: #011526;
--c-pdf-text: #f0efe9;
--c-badge-institution-bg: rgba(30, 80, 140, 0.25);
--c-badge-institution-text: #8bb8e0;
--c-badge-institution-border: rgba(30, 80, 140, 0.4);
--c-badge-group-bg: rgba(90, 45, 111, 0.25);
--c-badge-group-text: #c9a0dc;
--c-badge-group-border: rgba(90, 45, 111, 0.4);
--c-badge-unknown-bg: rgba(122, 90, 10, 0.25);
--c-badge-unknown-text: #e0c060;
--c-badge-unknown-border: rgba(122, 90, 10, 0.4);
}
}
@@ -186,6 +209,16 @@
--c-pdf-bg: #010e1e;
--c-pdf-ctrl: #011526;
--c-pdf-text: #f0efe9;
--c-badge-institution-bg: rgba(30, 80, 140, 0.25);
--c-badge-institution-text: #8bb8e0;
--c-badge-institution-border: rgba(30, 80, 140, 0.4);
--c-badge-group-bg: rgba(90, 45, 111, 0.25);
--c-badge-group-text: #c9a0dc;
--c-badge-group-border: rgba(90, 45, 111, 0.4);
--c-badge-unknown-bg: rgba(122, 90, 10, 0.25);
--c-badge-unknown-text: #e0c060;
--c-badge-unknown-border: rgba(122, 90, 10, 0.4);
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */

View File

@@ -39,8 +39,22 @@ const makeDoc = (overrides = {}) => ({
status: 'UPLOADED' as const,
documentDate: '2024-03-15',
location: 'Berlin',
sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }],
sender: {
id: 'p1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON' as const
},
receivers: [
{
id: 'p2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON' as const
}
],
tags: [{ id: 't1', name: 'Familie' }],
filePath: '/files/testbrief.pdf',
createdAt: '2024-03-15T10:00:00Z',

View File

@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import PersonTypeBadge from '$lib/components/PersonTypeBadge.svelte';
import PersonsStatsBar from './PersonsStatsBar.svelte';
import PersonsEmptyState from './PersonsEmptyState.svelte';
@@ -99,15 +100,48 @@ function handleSearch() {
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-primary font-serif text-base font-bold text-primary-fg transition-colors"
>
{person.firstName?.[0]}{person.lastName?.[0]}
{#if person.personType && person.personType !== 'PERSON'}
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
{#if person.personType === 'INSTITUTION'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
/>
{:else if person.personType === 'GROUP'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
/>
{/if}
</svg>
{:else}
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
{/if}
</div>
<!-- Name -->
<p class="font-serif text-sm font-bold text-ink group-hover:underline">
{person.firstName}
{person.lastName}
{person.displayName}
</p>
{#if person.personType && person.personType !== 'PERSON'}
<PersonTypeBadge personType={person.personType} />
{/if}
<!-- Alias -->
{#if person.alias}
<p class="font-sans text-xs text-ink-2 italic">{person.alias}"</p>

View File

@@ -23,7 +23,7 @@ const coCorrespondents = $derived.by(() => {
else
freq.set(key, {
id: receiver.id,
name: `${receiver.firstName} ${receiver.lastName}`,
name: receiver.displayName,
count: 1
});
}
@@ -37,7 +37,7 @@ const coCorrespondents = $derived.by(() => {
else
freq.set(key, {
id: doc.sender.id,
name: `${doc.sender.firstName} ${doc.sender.lastName}`,
name: doc.sender.displayName,
count: 1
});
}

View File

@@ -9,7 +9,7 @@ interface Props {
type: string;
sortOrder: number;
}>;
personFirstName: string;
personFirstName?: string | null;
}
let { aliases, personFirstName }: Props = $props();

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import PersonTypeBadge from '$lib/components/PersonTypeBadge.svelte';
let {
person,
@@ -8,8 +9,10 @@ let {
}: {
person: {
id: string;
firstName: string;
firstName?: string | null;
lastName: string;
displayName: string;
personType?: string | null;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
@@ -29,16 +32,51 @@ let {
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary font-serif text-xl font-bold text-primary-fg"
>
{person.firstName[0]}{person.lastName[0]}
{#if person.personType && person.personType !== 'PERSON'}
<svg
class="h-7 w-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
{#if person.personType === 'INSTITUTION'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
/>
{:else if person.personType === 'GROUP'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
/>
{/if}
</svg>
{:else}
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
{/if}
</div>
</div>
<!-- Name — centered, serif -->
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
{person.firstName}
{person.lastName}
{person.displayName}
</h1>
{#if person.personType && person.personType !== 'PERSON'}
<div class="mb-1 flex justify-center">
<PersonTypeBadge personType={person.personType} />
</div>
{/if}
<!-- Alias — centered, italic -->
{#if person.alias}
<p class="mb-1 text-center font-sans text-sm text-ink-2 italic">{person.alias}"</p>

View File

@@ -7,7 +7,7 @@ let {
person,
form
}: {
person: { firstName: string; lastName: string };
person: { displayName: string };
form?: { mergeError?: string } | null;
} = $props();
@@ -74,7 +74,7 @@ let showMergeConfirm = $state(false);
{#if showMergeConfirm}
<p class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong>
{m.person_merge_warning()} <strong>{person.displayName}</strong>
{m.person_merge_will_be_deleted()}
</p>
{/if}

View File

@@ -30,8 +30,7 @@ const person = $derived(data.person);
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
{person.firstName}
{person.lastName}
{person.displayName}
</a>
<h1 class="font-serif text-3xl text-ink">{m.person_edit_heading()}</h1>
</div>

View File

@@ -6,7 +6,7 @@ let {
person,
form
}: {
person: { id: string; firstName: string; lastName: string };
person: { id: string; firstName?: string | null; lastName: string; displayName: string };
form?: { mergeError?: string } | null;
} = $props();

View File

@@ -5,7 +5,7 @@ let {
person
}: {
person: {
firstName: string;
firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;

View File

@@ -11,6 +11,7 @@ const makePerson = (overrides = {}) => ({
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
documentCount: 0,
...overrides
});