From 1e1921e0fa4acf3652defce0d546bf1a2da2e2a2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 11:46:09 +0200 Subject: [PATCH 01/25] refactor(parser): expand SplitName record to 5 fields Add title, maidenName, and annotation fields (all nullable) to SplitName. All existing call sites pass null for new fields. Test assertions updated to document the null-by-default contract. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/PersonNameParser.java | 16 +++++++++++----- .../service/PersonNameParserTest.java | 12 ++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java index 2706d630..bb07e568 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java @@ -23,7 +23,13 @@ public class PersonNameParser { 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. @@ -118,7 +124,7 @@ public class PersonNameParser { */ 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(); @@ -132,15 +138,15 @@ public class PersonNameParser { 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 SplitName(null, firstName, lastName, null, null); } int lastSpace = cleaned.lastIndexOf(' '); if (lastSpace > 0) { - return new SplitName(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim()); + return new SplitName(null, cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim(), null, null); } - return new SplitName(cleaned, "?"); + return new SplitName(null, cleaned, "?", null, null); } /** Returns the known last name that the given string ends with, or null. */ diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java index 75eab4b2..6249b8aa 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java @@ -81,6 +81,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 +110,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()).isNull(); + 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 -- 2.49.1 From dea1635d759e855ae012ef33fbc032171d797801 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 11:48:08 +0200 Subject: [PATCH 02/25] refactor(parser): extract split() pipeline into named methods Extract stripMaidenName, normalizeDotCompressed, stripAnnotation, stripTitle, and splitByKnownLastNameOrFallback as individually testable pipeline steps. Each extraction method is a pass-through until its feature issue fills in the logic. Co-Authored-By: Claude Sonnet 4.6 --- .../service/PersonNameParser.java | 63 ++++++++++++++++--- .../service/PersonNameParserTest.java | 23 +++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java index bb07e568..d66db4fd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java @@ -118,35 +118,80 @@ 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(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); + + return new SplitName( + title.title(), parts.firstName(), parts.lastName(), + maiden.maidenName(), paren.annotation() + ); + } + + /** Strips "geb. Xxx" maiden-name annotations. Pass-through until #209. */ + public static MaidenNameResult stripMaidenName(String input) { + String cleaned = GEB_PATTERN.matcher(input).replaceAll("").trim(); + return new MaidenNameResult(cleaned, 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; + } + /** Strips parenthesized annotations. Pass-through until #210. */ + public static AnnotationResult stripAnnotation(String input) { + return new AnnotationResult(input, null); + } + + /** Strips title prefixes. Pass-through until #212. */ + public static TitleResult stripTitle(String input) { + return new TitleResult(input, null); + } + + /** 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(null, firstName, lastName, null, null); + return new NameParts(firstName, lastName); } int lastSpace = cleaned.lastIndexOf(' '); if (lastSpace > 0) { - return new SplitName(null, cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim(), null, null); + return new NameParts(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim()); } - return new SplitName(null, cleaned, "?", null, null); + return new NameParts(cleaned, "?"); } /** Returns the known last name that the given string ends with, or null. */ diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java index 6249b8aa..deecacd3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java @@ -256,4 +256,27 @@ class PersonNameParserTest { List 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_isPassthrough() { + PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Walter de Gruyter"); + assertThat(result.cleaned()).isEqualTo("Walter de Gruyter"); + assertThat(result.annotation()).isNull(); + } + + @Test + void stripTitle_isPassthrough() { + PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Walter de Gruyter"); + assertThat(result.cleaned()).isEqualTo("Walter de Gruyter"); + assertThat(result.title()).isNull(); + } } -- 2.49.1 From 8101ddb697d42acc1e45c6a0ccf07ab67cf32b14 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 11:50:19 +0200 Subject: [PATCH 03/25] feat(model): add PersonType enum and MAIDEN_NAME alias type PersonType has 5 values: PERSON, INSTITUTION, GROUP, UNKNOWN, SKIP. SKIP is intentionally excluded from the DB CHECK constraint (added in migration) as defense-in-depth. MAIDEN_NAME added to PersonNameAliasType for #209. Co-Authored-By: Claude Sonnet 4.6 --- .../model/PersonNameAliasType.java | 1 + .../raddatz/familienarchiv/model/PersonType.java | 9 +++++++++ .../service/PersonNameParserTest.java | 16 ++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java index 38ad90cf..e4c089e6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java @@ -4,5 +4,6 @@ public enum PersonNameAliasType { BIRTH, WIDOWED, DIVORCED, + MAIDEN_NAME, OTHER } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java new file mode 100644 index 00000000..db95b5bc --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.model; + +public enum PersonType { + PERSON, + INSTITUTION, + GROUP, + UNKNOWN, + SKIP +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java index deecacd3..a1148fa7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java @@ -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; @@ -279,4 +281,18 @@ class PersonNameParserTest { assertThat(result.cleaned()).isEqualTo("Walter de Gruyter"); assertThat(result.title()).isNull(); } + + // --- 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); + } } -- 2.49.1 From 9f14648dc32aeda5c1d24ef2edc319885fb5813a Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 11:53:07 +0200 Subject: [PATCH 04/25] feat(model): add title, personType, displayName to Person entity - Add title (nullable VARCHAR) and personType (enum, default PERSON) - Make firstName nullable for non-person entities - Add @Transient getDisplayName() as single source of truth for name display, exposed via @Schema(READ_ONLY, REQUIRED) Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/model/Person.java | 22 +++++++- .../familienarchiv/model/PersonTest.java | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java index 3bd6f418..b359fd7b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java @@ -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,14 @@ public class Person { @JsonIgnore @Builder.Default private List nameAliases = new ArrayList<>(); + + @Transient + @Schema(accessMode = Schema.AccessMode.READ_ONLY, requiredMode = Schema.RequiredMode.REQUIRED) + public String getDisplayName() { + 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(); + } } \ No newline at end of file diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java new file mode 100644 index 00000000..72674925 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java @@ -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); + } +} -- 2.49.1 From 92f1a112f5bbf8c21e3c63d81da296b65207faca Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 11:55:04 +0200 Subject: [PATCH 05/25] feat(migration): V22 add title, person_type, nullable first_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add title VARCHAR(50) column - Add person_type VARCHAR(20) NOT NULL DEFAULT 'PERSON' with CHECK constraint (PERSON, INSTITUTION, GROUP, UNKNOWN — SKIP excluded) - Drop NOT NULL on first_name for non-person entities Co-Authored-By: Claude Sonnet 4.6 --- .../V22__person_type_title_nullable_firstname.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql diff --git a/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql b/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql new file mode 100644 index 00000000..a01b6373 --- /dev/null +++ b/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql @@ -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; -- 2.49.1 From de2cc677a9720e7ef6952642e27383ca4b3d53f3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 11:59:41 +0200 Subject: [PATCH 06/25] fix(search): handle null firstName in all search queries Use COALESCE to convert null firstName to empty string in: - PersonRepository.searchByName (JPQL) - PersonRepository.searchWithDocumentCount (native SQL) - PersonRepository.findCorrespondentsWithFilter (native SQL) - DocumentSpecifications.hasText (Criteria API, sender + receiver) Co-Authored-By: Claude Sonnet 4.6 --- .../repository/DocumentSpecifications.java | 4 ++-- .../repository/PersonRepository.java | 12 +++++----- .../repository/PersonRepositoryTest.java | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java index ee9550c1..d8d572bc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -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), diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index abbed802..e41be561 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -16,8 +16,8 @@ import org.springframework.stereotype.Repository; public interface PersonRepository extends JpaRepository { @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") @@ -52,8 +52,8 @@ public interface PersonRepository extends JpaRepository { + (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 @@ -98,8 +98,8 @@ public interface PersonRepository extends JpaRepository { 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 diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java index b0873d35..26eccf6a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java @@ -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 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 result = personRepository.searchWithDocumentCount("Gesellschafter"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages"); + } } -- 2.49.1 From 0ce803c7f194b60e72dd585459e61cd41ab88972 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 12:06:38 +0200 Subject: [PATCH 07/25] build(frontend): regenerate API types for Person changes Person type now includes displayName (readonly, required), title, personType (required enum), and firstName is optional. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 534c8066..a994b73b 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -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 */ @@ -1228,10 +1231,10 @@ export interface components { /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; - /** Format: int32 */ - numberOfElements?: number; first?: boolean; last?: boolean; + /** Format: int32 */ + numberOfElements?: number; empty?: boolean; }; PageableObject: { -- 2.49.1 From f11d8a38edf9424df28d87d170ae3ff282cd7a9b Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 12:22:30 +0200 Subject: [PATCH 08/25] feat(frontend): replace all name concatenation with displayName - Add displayName default method to PersonSummaryDTO - Update native SQL queries to include title, person_type columns - Add getInitials() utility to personFormat.ts - Update abbreviateName/abbreviateCompact for nullable firstName - Replace firstName+lastName concatenation with displayName in all person-displaying components and server load files - Regenerate API types with displayName on Person and PersonSummaryDTO Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/dto/PersonSummaryDTO.java | 10 ++++++++++ .../repository/PersonRepository.java | 8 +++++--- .../controller/PersonControllerTest.java | 2 ++ .../components/DashboardRecentDocuments.svelte | 2 +- .../components/DocumentMetadataDrawer.svelte | 8 ++++---- .../src/lib/components/DocumentTopBar.svelte | 2 +- .../lib/components/OverflowPillButton.svelte | 5 ++--- frontend/src/lib/components/PersonChip.svelte | 14 +++++--------- .../src/lib/components/PersonChipRow.svelte | 2 +- .../lib/components/PersonMultiSelect.svelte | 5 ++--- .../src/lib/components/PersonTypeahead.svelte | 4 ++-- frontend/src/lib/generated/api.ts | 7 +++++-- frontend/src/lib/utils/personFormat.ts | 9 ++++++++- frontend/src/routes/+page.server.ts | 4 ++-- frontend/src/routes/DocumentList.svelte | 8 ++++---- .../src/routes/briefwechsel/+page.server.ts | 8 ++++---- .../briefwechsel/ConversationTimeline.svelte | 13 +++++++++---- .../CorrespondentSuggestionsDropdown.svelte | 9 ++++++--- .../src/routes/conversations/+page.server.ts | 8 ++++---- .../conversations/ConversationTimeline.svelte | 9 +++++++-- .../routes/documents/[id]/edit/+page.svelte | 2 +- .../src/routes/documents/new/+page.server.ts | 18 +++++++++++++++--- frontend/src/routes/documents/new/+page.svelte | 5 ++--- frontend/src/routes/enrich/[id]/+page.svelte | 2 +- frontend/src/routes/persons/+page.svelte | 5 ++--- frontend/src/routes/persons/[id]/+page.svelte | 4 ++-- .../routes/persons/[id]/NameHistoryCard.svelte | 2 +- .../src/routes/persons/[id]/PersonCard.svelte | 8 ++++---- .../persons/[id]/PersonMergePanel.svelte | 4 ++-- .../src/routes/persons/[id]/edit/+page.svelte | 3 +-- .../persons/[id]/edit/PersonDangerZone.svelte | 2 +- .../persons/[id]/edit/PersonEditForm.svelte | 2 +- 32 files changed, 117 insertions(+), 77 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java index d31539ac..edb448f8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java @@ -9,11 +9,21 @@ 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() { + StringBuilder sb = new StringBuilder(); + if (getTitle() != null) sb.append(getTitle()).append(" "); + if (getFirstName() != null) sb.append(getFirstName()).append(" "); + sb.append(getLastName()); + return sb.toString().trim(); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index e41be561..782dde24 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -35,7 +35,8 @@ public interface PersonRepository extends JpaRepository { // --- 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,7 +47,8 @@ public interface PersonRepository extends JpaRepository { List 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 @@ -56,7 +58,7 @@ public interface PersonRepository extends JpaRepository { 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) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index bd41be36..02973927 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -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; } diff --git a/frontend/src/lib/components/DashboardRecentDocuments.svelte b/frontend/src/lib/components/DashboardRecentDocuments.svelte index bfe10e0f..25b93c8b 100644 --- a/frontend/src/lib/components/DashboardRecentDocuments.svelte +++ b/frontend/src/lib/components/DashboardRecentDocuments.svelte @@ -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']; diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte b/frontend/src/lib/components/DocumentMetadataDrawer.svelte index 22e8b13a..d057a993 100644 --- a/frontend/src/lib/components/DocumentMetadataDrawer.svelte +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte @@ -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; } diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index 7d610706..c7604e7c 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -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 = { diff --git a/frontend/src/lib/components/OverflowPillButton.svelte b/frontend/src/lib/components/OverflowPillButton.svelte index 5541edc7..3661b5c4 100644 --- a/frontend/src/lib/components/OverflowPillButton.svelte +++ b/frontend/src/lib/components/OverflowPillButton.svelte @@ -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} {/each} diff --git a/frontend/src/lib/components/PersonChip.svelte b/frontend/src/lib/components/PersonChip.svelte index d17e7365..97796c00 100644 --- a/frontend/src/lib/components/PersonChip.svelte +++ b/frontend/src/lib/components/PersonChip.svelte @@ -1,7 +1,7 @@ {initials} - {displayName} + {name} diff --git a/frontend/src/lib/components/PersonChipRow.svelte b/frontend/src/lib/components/PersonChipRow.svelte index e50ed3e3..d292e22d 100644 --- a/frontend/src/lib/components/PersonChipRow.svelte +++ b/frontend/src/lib/components/PersonChipRow.svelte @@ -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; diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte index 536ef97b..7847787f 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte +++ b/frontend/src/lib/components/PersonMultiSelect.svelte @@ -73,8 +73,7 @@ function removePerson(id: string | undefined) { - {person.firstName} - {person.lastName} + {person.displayName}