From e9fc0a215eaeba9c7f45880f046a8d533b7347ae Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 17:43:05 +0200 Subject: [PATCH 01/16] feat(person): add BIRTH_AFTER_DEATH and INVALID_DATE_PRECISION error codes Backend enum, frontend ErrorCode mirror, getErrorMessage cases, and error message i18n keys (de/en/es) incl. the mixed-precision workaround hint in error_birth_after_death. Co-Authored-By: Claude Fable 5 --- .../org/raddatz/familienarchiv/exception/ErrorCode.java | 4 ++++ frontend/messages/de.json | 2 ++ frontend/messages/en.json | 2 ++ frontend/messages/es.json | 2 ++ frontend/src/lib/shared/errors.ts | 6 ++++++ 5 files changed, 16 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 84caab27..5c6b6d95 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -15,6 +15,10 @@ public enum ErrorCode { ALIAS_NOT_FOUND, /** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */ INVALID_PERSON_TYPE, + /** A person's birth date is after their death date. 400 */ + BIRTH_AFTER_DEATH, + /** A life date and its precision are incoherent: date present with UNKNOWN precision, or precision set without a date. 400 */ + INVALID_DATE_PRECISION, // --- Documents --- /** A document with the given ID does not exist. 404 */ DOCUMENT_NOT_FOUND, diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 961597fe..8cc65867 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -643,6 +643,8 @@ "error_alias_not_found": "Der Namensalias wurde nicht gefunden.", "error_invalid_person_type": "Der angegebene Personentyp ist ungültig.", "error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.", + "error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.", + "error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein", "validation_last_name_required": "Nachname ist Pflichtfeld.", "validation_first_name_required": "Vorname ist Pflichtfeld.", "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7d957e6a..9f640189 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -643,6 +643,8 @@ "error_alias_not_found": "The name alias was not found.", "error_invalid_person_type": "The specified person type is not valid.", "error_invalid_date_range": "The end date must not be before the start date.", + "error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.", + "error_invalid_date_precision": "Date and precision do not match", "validation_last_name_required": "Last name is required.", "validation_first_name_required": "First name is required.", "error_ocr_service_unavailable": "The OCR service is not available.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 6ad45fcc..d44b21a6 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -643,6 +643,8 @@ "error_alias_not_found": "No se encontro el alias de nombre.", "error_invalid_person_type": "El tipo de persona especificado no es válido.", "error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.", + "error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.", + "error_invalid_date_precision": "La fecha y la precisión no coinciden", "validation_last_name_required": "El apellido es obligatorio.", "validation_first_name_required": "El nombre es obligatorio.", "error_ocr_service_unavailable": "El servicio OCR no está disponible.", diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index e06f37d4..344fc08c 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -8,6 +8,8 @@ export type ErrorCode = | 'PERSON_NOT_FOUND' | 'ALIAS_NOT_FOUND' | 'INVALID_PERSON_TYPE' + | 'BIRTH_AFTER_DEATH' + | 'INVALID_DATE_PRECISION' | 'INVALID_DATE_RANGE' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' @@ -96,6 +98,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_alias_not_found(); case 'INVALID_PERSON_TYPE': return m.error_invalid_person_type(); + case 'BIRTH_AFTER_DEATH': + return m.error_birth_after_death(); + case 'INVALID_DATE_PRECISION': + return m.error_invalid_date_precision(); case 'INVALID_DATE_RANGE': return m.error_invalid_date_range(); case 'DOCUMENT_NOT_FOUND': -- 2.49.1 From 79019ce25fae768fbc46983b35a9f2ac2c6609eb Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 17:47:29 +0200 Subject: [PATCH 02/16] =?UTF-8?q?feat(person):=20V76=20migration=20?= =?UTF-8?q?=E2=80=94=20birth/death=20year=20to=20date=20+=20precision=20co?= =?UTF-8?q?lumns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-check aborts on corrupt year data, backfills YYYY-01-01/YEAR, adds five named CHECK constraints, drops birth_year/death_year. Staged-Flyway Testcontainers test covers pre-check aborts, backfill shapes, and post-migration schema. Co-Authored-By: Claude Fable 5 --- .../V76__person_birth_death_to_localdate.sql | 42 +++ .../person/PersonBirthDeathMigrationTest.java | 244 ++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V76__person_birth_death_to_localdate.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/person/PersonBirthDeathMigrationTest.java diff --git a/backend/src/main/resources/db/migration/V76__person_birth_death_to_localdate.sql b/backend/src/main/resources/db/migration/V76__person_birth_death_to_localdate.sql new file mode 100644 index 00000000..c8924d96 --- /dev/null +++ b/backend/src/main/resources/db/migration/V76__person_birth_death_to_localdate.sql @@ -0,0 +1,42 @@ +-- V76: persons.birth_year/death_year (integer) → birth_date/death_date (date) +-- plus NOT NULL precision columns mirroring documents.meta_date_precision. +-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-039). +-- One-way migration: rollback is a targeted pg_restore -t persons from the +-- pre-deploy backup (see docs/DEPLOYMENT.md). + +-- Pre-check (data quality gate — not a race guard): abort on corrupt year data +-- before any DDL runs. Single-writer family archive, so no race window matters. +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year) + THEN RAISE EXCEPTION 'V76 aborted: % persons have birth_year > death_year — fix data before migrating', + (SELECT COUNT(*) FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year); + END IF; + IF EXISTS (SELECT 1 FROM persons WHERE birth_year = 0 OR death_year = 0) + THEN RAISE EXCEPTION 'V76 aborted: persons table contains birth_year=0 or death_year=0 rows — clean data before migrating'; + END IF; +END $$; + +ALTER TABLE persons ADD COLUMN birth_date date; +ALTER TABLE persons ADD COLUMN birth_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN'; +ALTER TABLE persons ADD COLUMN death_date date; +ALTER TABLE persons ADD COLUMN death_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN'; + +UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR' + WHERE birth_year IS NOT NULL; +UPDATE persons SET death_date = make_date(death_year, 1, 1), death_date_precision = 'YEAR' + WHERE death_year IS NOT NULL; + +-- Named constraints: readable Postgres error messages when violated. +ALTER TABLE persons ADD CONSTRAINT chk_person_birth_before_death + CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date); +ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_coherence + CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN')); +ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_values + CHECK (birth_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN')); +ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_coherence + CHECK ((death_date IS NULL) = (death_date_precision = 'UNKNOWN')); +ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_values + CHECK (death_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN')); + +ALTER TABLE persons DROP COLUMN birth_year; +ALTER TABLE persons DROP COLUMN death_year; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonBirthDeathMigrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonBirthDeathMigrationTest.java new file mode 100644 index 00000000..15a67a5c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonBirthDeathMigrationTest.java @@ -0,0 +1,244 @@ +package org.raddatz.familienarchiv.person; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies V76: persons.birth_year/death_year (integer) become + * birth_date/death_date (date) + *_date_precision columns, with backfill to + * YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality + * pre-check that aborts the migration on corrupt year data. + * + *

Runs Flyway programmatically (no Spring context): each test gets its own + * database so the staged migrate-to-V75 → seed → migrate-to-latest flow and + * the abort cases cannot interfere with each other. + */ +class PersonBirthDeathMigrationTest { + + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + private static final AtomicInteger DB_COUNTER = new AtomicInteger(); + + private String dbUrl; + + @BeforeAll + static void startContainer() { + POSTGRES.start(); + } + + @AfterAll + static void stopContainer() { + POSTGRES.stop(); + } + + @BeforeEach + void createFreshDatabase() throws SQLException { + String dbName = "mig_v76_" + DB_COUNTER.incrementAndGet(); + try (Connection conn = DriverManager.getConnection( + baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword()); + Statement stmt = conn.createStatement()) { + stmt.execute("CREATE DATABASE " + dbName); + } + dbUrl = baseUrl(dbName); + } + + @Test + void precheck_abortsWhenBirthYearAfterDeathYear() throws SQLException { + migrateTo("75"); + seedPerson("Corrupt", 1950, 1940); + + assertThatThrownBy(this::migrateToLatest) + .hasMessageContaining("V76 aborted") + .hasMessageContaining("birth_year > death_year"); + } + + @Test + void precheck_abortsWhenYearZeroPresent() throws SQLException { + migrateTo("75"); + seedPerson("Zero", 0, null); + + assertThatThrownBy(this::migrateToLatest) + .hasMessageContaining("V76 aborted") + .hasMessageContaining("birth_year=0 or death_year=0"); + } + + @Test + void backfill_birthOnly_becomesYearPrecisionDate_deathStaysUnknown() throws SQLException { + migrateTo("75"); + seedPerson("BirthOnly", 1901, null); + + migrateToLatest(); + + LifeDates row = lifeDates("BirthOnly"); + assertThat(row.birthDate()).hasToString("1901-01-01"); + assertThat(row.birthPrecision()).isEqualTo("YEAR"); + assertThat(row.deathDate()).isNull(); + assertThat(row.deathPrecision()).isEqualTo("UNKNOWN"); + } + + @Test + void backfill_deathOnly_becomesYearPrecisionDate_birthStaysUnknown() throws SQLException { + migrateTo("75"); + seedPerson("DeathOnly", null, 1944); + + migrateToLatest(); + + LifeDates row = lifeDates("DeathOnly"); + assertThat(row.birthDate()).isNull(); + assertThat(row.birthPrecision()).isEqualTo("UNKNOWN"); + assertThat(row.deathDate()).hasToString("1944-01-01"); + assertThat(row.deathPrecision()).isEqualTo("YEAR"); + } + + @Test + void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException { + migrateTo("75"); + seedPerson("NoDates", null, null); + + migrateToLatest(); + + LifeDates row = lifeDates("NoDates"); + assertThat(row.birthDate()).isNull(); + assertThat(row.birthPrecision()).isEqualTo("UNKNOWN"); + assertThat(row.deathDate()).isNull(); + assertThat(row.deathPrecision()).isEqualTo("UNKNOWN"); + } + + @Test + void backfill_neverProducesBirthDateAfterDeathDate() throws SQLException { + migrateTo("75"); + seedPerson("SameYear", 1901, 1901); + seedPerson("Ordered", 1899, 1972); + + migrateToLatest(); + + assertThat(countWhere("birth_date IS NOT NULL AND death_date IS NOT NULL AND birth_date > death_date")) + .isZero(); + } + + @Test + void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException { + migrateTo("75"); + seedPerson("Schema", 1901, 1944); + + migrateToLatest(); + + assertThat(columnExists("birth_year")).isFalse(); + assertThat(columnExists("death_year")).isFalse(); + assertThat(columnExists("birth_date")).isTrue(); + assertThat(columnExists("death_date")).isTrue(); + for (String constraint : new String[]{ + "chk_person_birth_before_death", + "chk_person_birth_date_precision_coherence", + "chk_person_birth_date_precision_values", + "chk_person_death_date_precision_coherence", + "chk_person_death_date_precision_values"}) { + assertThat(constraintExists(constraint)).as(constraint).isTrue(); + } + } + + // --- helpers --- + + private static String baseUrl(String dbName) { + return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName; + } + + private void migrateTo(String targetVersion) { + flywayBuilder().target(targetVersion).load().migrate(); + } + + private void migrateToLatest() { + flywayBuilder().load().migrate(); + } + + private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() { + return Flyway.configure() + .dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .placeholders(Map.of("grafanaDbPassword", "test-only")); + } + + private void seedPerson(String lastName, Integer birthYear, Integer deathYear) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO persons (id, last_name, person_type, family_member, provisional, birth_year, death_year) " + + "VALUES (gen_random_uuid(), ?, 'PERSON', false, false, ?, ?)")) { + stmt.setString(1, lastName); + stmt.setObject(2, birthYear); + stmt.setObject(3, deathYear); + stmt.executeUpdate(); + } + } + + private record LifeDates(Object birthDate, String birthPrecision, Object deathDate, String deathPrecision) {} + + private LifeDates lifeDates(String lastName) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT birth_date, birth_date_precision, death_date, death_date_precision " + + "FROM persons WHERE last_name = ?")) { + stmt.setString(1, lastName); + try (ResultSet rs = stmt.executeQuery()) { + assertThat(rs.next()).as("person %s exists", lastName).isTrue(); + return new LifeDates( + rs.getObject("birth_date"), + rs.getString("birth_date_precision"), + rs.getObject("death_date"), + rs.getString("death_date_precision")); + } + } + } + + private long countWhere(String condition) throws SQLException { + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM persons WHERE " + condition)) { + rs.next(); + return rs.getLong(1); + } + } + + private boolean columnExists(String columnName) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT COUNT(*) FROM information_schema.columns " + + "WHERE table_schema = 'public' AND table_name = 'persons' AND column_name = ?")) { + stmt.setString(1, columnName); + try (ResultSet rs = stmt.executeQuery()) { + rs.next(); + return rs.getInt(1) > 0; + } + } + } + + private boolean constraintExists(String constraintName) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) { + stmt.setString(1, constraintName); + try (ResultSet rs = stmt.executeQuery()) { + rs.next(); + return rs.getInt(1) > 0; + } + } + } + + private Connection connect() throws SQLException { + return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword()); + } +} -- 2.49.1 From bac38a02b6aec33f4ce50efc33be3d312fedf234 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 18:00:05 +0200 Subject: [PATCH 03/16] feat(person): store birth/death as LocalDate + DatePrecision Entity swap mirroring Document.metaDatePrecision; PersonUpdateDTO takes date + precision; validateLifeDates (badRequest BIRTH_AFTER_DEATH / INVALID_DATE_PRECISION) replaces validateYears; preferHumanDate keeps DAY/MONTH/SEASON hand-entered dates on re-import and refreshes YEAR/UNKNOWN from the canonical year (ADR-025 extension); PersonUpsertCommand stays year-shaped. Native queries project EXTRACT(YEAR ...) so PersonSummaryDTO and PersonNodeDTO stay year-shaped, null-safe for undated persons. Co-Authored-By: Claude Fable 5 --- .../raddatz/familienarchiv/person/Person.java | 23 ++- .../person/PersonRepository.java | 14 +- .../familienarchiv/person/PersonService.java | 89 ++++++++-- .../person/PersonUpdateDTO.java | 9 +- .../RelationshipInferenceService.java | 4 +- .../relationship/RelationshipService.java | 18 +- .../person/PersonControllerTest.java | 43 ++++- .../person/PersonImportUpsertTest.java | 116 ++++++++++++- .../person/PersonRepositoryTest.java | 60 +++++++ .../person/PersonServiceTest.java | 156 ++++++++++++------ 10 files changed, 433 insertions(+), 99 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java index 84058e2f..74783718 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/Person.java @@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.*; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.user.DisplayNameFormatter; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -49,8 +51,25 @@ public class Person { @Column(columnDefinition = "TEXT") private String notes; - private Integer birthYear; - private Integer deathYear; + // Most precise birth/death date known. Precision mirrors Document.metaDatePrecision: + // the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning + // "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN). + // DatePrecision is imported cross-domain from document/ by design (ADR-039). + private LocalDate birthDate; + + @Enumerated(EnumType.STRING) + @Column(name = "birth_date_precision", nullable = false, length = 16) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN; + + private LocalDate deathDate; + + @Enumerated(EnumType.STRING) + @Column(name = "death_date_precision", nullable = false, length = 16) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Builder.Default + private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN; // Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest). // Nullable for persons outside the curated family graph. Drives the diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 0afc9e66..9e14e48e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -66,7 +66,8 @@ public interface PersonRepository extends JpaRepository { @Query(value = """ 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, + p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear, + CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes, p.family_member AS familyMember, p.provisional AS provisional, (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 @@ -79,7 +80,8 @@ public interface PersonRepository extends JpaRepository { @Query(value = """ 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, + p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear, + CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes, p.family_member AS familyMember, p.provisional AS provisional, (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 @@ -89,7 +91,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.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional + GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional ORDER BY p.last_name ASC, p.first_name ASC """, nativeQuery = true) @@ -100,7 +102,8 @@ public interface PersonRepository extends JpaRepository { @Query(value = """ 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, + p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear, + CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes, p.family_member AS familyMember, p.provisional AS provisional, (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 @@ -139,7 +142,8 @@ public interface PersonRepository extends JpaRepository { @Query(value = """ 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, + p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear, + CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear, p.notes, p.family_member AS familyMember, p.provisional AS provisional, (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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index d195d0bb..0608639b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -1,5 +1,6 @@ package org.raddatz.familienarchiv.person; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -16,6 +17,7 @@ import org.springframework.lang.Nullable; import org.raddatz.familienarchiv.person.PersonNameAliasDTO; import org.raddatz.familienarchiv.person.PersonSummaryDTO; import org.raddatz.familienarchiv.person.PersonUpdateDTO; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; @@ -299,13 +301,17 @@ public class PersonService { } private Person fromCanonical(PersonUpsertCommand cmd) { + DatePrecisionPair birth = yearPair(cmd.birthYear()); + DatePrecisionPair death = yearPair(cmd.deathYear()); Person person = personRepository.save(Person.builder() .sourceRef(cmd.sourceRef()) .firstName(blankToNull(cmd.firstName())) .lastName(cmd.lastName()) .notes(blankToNull(cmd.notes())) - .birthYear(cmd.birthYear()) - .deathYear(cmd.deathYear()) + .birthDate(birth.date()) + .birthDatePrecision(birth.precision()) + .deathDate(death.date()) + .deathDatePrecision(death.precision()) .generation(cmd.generation()) .familyMember(cmd.familyMember()) .personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType()) @@ -328,8 +334,14 @@ public class PersonService { existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName())); existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName())); existing.setNotes(preferHuman(existing.getNotes(), cmd.notes())); - existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear())); - existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear())); + DatePrecisionPair birth = preferHumanDate( + existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()); + existing.setBirthDate(birth.date()); + existing.setBirthDatePrecision(birth.precision()); + DatePrecisionPair death = preferHumanDate( + existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()); + existing.setDeathDate(death.date()); + existing.setDeathDatePrecision(death.precision()); existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation())); if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) { existing.setPersonType(cmd.personType()); @@ -356,6 +368,28 @@ public class PersonService { return existing != null ? existing : canonical; } + // Date + precision travel as one value so they can never go out of sync (ADR-039). + record DatePrecisionPair(LocalDate date, DatePrecision precision) {} + + // preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than + // the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a + // YEAR-precision or absent date is refreshed from the canonical year. + static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision, + Integer canonicalYear) { + boolean handEntered = existingDate != null && existingPrecision != null + && existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN; + if (handEntered) { + return new DatePrecisionPair(existingDate, existingPrecision); + } + return yearPair(canonicalYear); + } + + private static DatePrecisionPair yearPair(Integer year) { + return year != null + ? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR) + : new DatePrecisionPair(null, DatePrecision.UNKNOWN); + } + private static String blankToNull(String s) { return (s == null || s.isBlank()) ? null : s.trim(); } @@ -375,7 +409,8 @@ public class PersonService { if (dto.getPersonType() == PersonType.SKIP) { throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation"); } - validateYears(dto.getBirthYear(), dto.getDeathYear()); + validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(), + dto.getDeathDate(), dto.getDeathDatePrecision()); Person person = Person.builder() .personType(dto.getPersonType()) .title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()) @@ -383,31 +418,49 @@ public class PersonService { .lastName(dto.getLastName()) .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()) .notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()) - .birthYear(dto.getBirthYear()) - .deathYear(dto.getDeathYear()) + .birthDate(dto.getBirthDate()) + .birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())) + .deathDate(dto.getDeathDate()) + .deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision())) .generation(dto.getGeneration()) .build(); return personRepository.save(person); } - private void validateYears(Integer birthYear, Integer deathYear) { - if (birthYear != null && birthYear <= 0) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein"); + // Cross-field invariants the V76 CHECK constraints also enforce — validated here so the + // user gets a structured ErrorCode instead of a raw constraint-violation 500. + private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision, + LocalDate deathDate, DatePrecision deathPrecision) { + requireDatePrecisionCoherence(birthDate, birthPrecision, "birth"); + requireDatePrecisionCoherence(deathDate, deathPrecision, "death"); + if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) { + throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH, + "Birth date " + birthDate + " is after death date " + deathDate); } - if (deathYear != null && deathYear <= 0) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein"); + } + + private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) { + if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION, + side + " date is set but its precision is missing or UNKNOWN"); } - if (birthYear != null && deathYear != null && birthYear > deathYear) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen"); + if (date == null && precision != null && precision != DatePrecision.UNKNOWN) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION, + side + " date precision " + precision + " is set without a date"); } } + private static DatePrecision normalizePrecision(DatePrecision precision) { + return precision == null ? DatePrecision.UNKNOWN : precision; + } + @Transactional public Person updatePerson(UUID id, PersonUpdateDTO dto) { if (dto.getPersonType() == PersonType.SKIP) { throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing"); } - validateYears(dto.getBirthYear(), dto.getDeathYear()); + validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(), + dto.getDeathDate(), dto.getDeathDatePrecision()); Person person = personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); person.setPersonType(dto.getPersonType()); @@ -416,8 +469,10 @@ public class PersonService { person.setLastName(dto.getLastName()); person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()); person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()); - person.setBirthYear(dto.getBirthYear()); - person.setDeathYear(dto.getDeathYear()); + person.setBirthDate(dto.getBirthDate()); + person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())); + person.setDeathDate(dto.getDeathDate()); + person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision())); // Form path: a human can clear generation back to null. Unlike the importer // which routes through preferHuman, we write the DTO value verbatim. person.setGeneration(dto.getGeneration()); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java index a3cd9aca..483cbef9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpdateDTO.java @@ -5,8 +5,11 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.person.PersonType; +import java.time.LocalDate; + @Data public class PersonUpdateDTO { @NotNull @@ -21,8 +24,10 @@ public class PersonUpdateDTO { private String alias; @Size(max = 5000) private String notes; - private Integer birthYear; - private Integer deathYear; + private LocalDate birthDate; + private DatePrecision birthDatePrecision; + private LocalDate deathDate; + private DatePrecision deathDatePrecision; // Mirror of the persons.generation CHECK constraint (V70). Bounds live in // PersonGeneration so DB, DTO, and importer all read from one place. @Min(PersonGeneration.MIN_GENERATION) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java index c93e0208..96d7fa50 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java @@ -96,7 +96,9 @@ public class RelationshipInferenceService { if (p == null) continue; List path = shortestPaths.get(id); PersonNodeDTO node = new PersonNodeDTO( - p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), + p.getId(), p.getDisplayName(), + p.getBirthDate() != null ? p.getBirthDate().getYear() : null, + p.getDeathDate() != null ? p.getDeathDate().getYear() : null, p.getGeneration(), p.isFamilyMember()); out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size())); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java index 9c8096ab..e764da04 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java @@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -66,7 +67,8 @@ public class RelationshipService { for (Person p : familyMembers) { familyIds.add(p.getId()); nodes.add(new PersonNodeDTO( - p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), + p.getId(), p.getDisplayName(), + yearOf(p.getBirthDate()), yearOf(p.getDeathDate()), p.getGeneration(), true)); } @@ -155,6 +157,12 @@ public class RelationshipService { return (s == null || s.isBlank()) ? null : s.trim(); } + // Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe + // for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). + private static Integer yearOf(LocalDate date) { + return date != null ? date.getYear() : null; + } + private static void validateYears(Integer fromYear, Integer toYear) { if (fromYear != null && toYear != null && toYear < fromYear) { throw DomainException.badRequest( @@ -170,11 +178,11 @@ public class RelationshipService { p.getId(), rp.getId(), p.getDisplayName(), - p.getBirthYear(), - p.getDeathYear(), + yearOf(p.getBirthDate()), + yearOf(p.getDeathDate()), rp.getDisplayName(), - rp.getBirthYear(), - rp.getDeathYear(), + yearOf(rp.getBirthDate()), + yearOf(rp.getDeathDate()), r.getRelationType(), r.getFromYear(), r.getToYear(), diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java index 783924c0..60231c94 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonNameAlias; @@ -22,6 +23,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.UUID; @@ -572,18 +574,53 @@ class PersonControllerTest { void createPerson_returns200_withAllSixFields() throws Exception { UUID id = UUID.randomUUID(); Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz") - .alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build(); + .alias("Oma Maria") + .birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY) + .deathDate(LocalDate.of(1975, 1, 1)).deathDatePrecision(DatePrecision.YEAR) + .notes("Some notes").build(); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," + - "\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," + + "\"alias\":\"Oma Maria\"," + + "\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"DAY\"," + + "\"deathDate\":\"1975-01-01\",\"deathDatePrecision\":\"YEAR\"," + "\"notes\":\"Some notes\",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.firstName").value("Maria")) .andExpect(jsonPath("$.alias").value("Oma Maria")) - .andExpect(jsonPath("$.birthYear").value(1901)); + .andExpect(jsonPath("$.birthDate").value("1901-03-14")) + .andExpect(jsonPath("$.birthDatePrecision").value("DAY")); + } + + // ─── #773: malformed date payloads return structured 400s, not Jackson traces ── + // Jackson rejects unknown enum values by default. Verified 2026-06-12: the only + // DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper + // (OCR HTTP client) — the Spring MVC mapper has no READ_UNKNOWN_ENUM_VALUES_* override. + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updatePerson_returns400WithStructuredErrorCode_whenPrecisionEnumInvalid() throws Exception { + UUID id = UUID.randomUUID(); + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," + + "\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"INVALID_VALUE\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updatePerson_returns400WithStructuredErrorCode_whenBirthDateNotADate() throws Exception { + UUID id = UUID.randomUUID(); + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," + + "\"birthDate\":\"not-a-date\",\"birthDatePrecision\":\"DAY\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")); } // ─── Phase 1.2: @Size constraints ───────────────────────────────────────── diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java index 9b778292..b14b2691 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java @@ -5,7 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.document.DatePrecision; +import java.time.LocalDate; import java.util.Optional; import java.util.UUID; @@ -97,24 +99,120 @@ class PersonImportUpsertTest { assertThat(result.getNotes()).isEqualTo("Nichte von Herbert"); } + // ─── life dates (ADR-025 extension via preferHumanDate, #773) ───────────── + @Test - void upsertBySourceRef_fillsBlankYears_butPreservesHumanEditedYears_onReimport() { - // Existing has a human-set birthYear and a blank deathYear. - Person existing = Person.builder() - .id(UUID.randomUUID()).sourceRef("clara-cram") - .lastName("Cram").birthYear(1890).deathYear(null).build(); - when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing)); + void upsertBySourceRef_preservesDayPrecisionDate_onReimportWithDifferentYear() { + // A human entered the exact birthday in-app; the spreadsheet only knows a year. + Person handDated = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram") + .birthDate(LocalDate.of(1890, 3, 14)).birthDatePrecision(DatePrecision.DAY).build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); PersonUpsertCommand cmd = PersonUpsertCommand.builder() .sourceRef("clara-cram").lastName("Cram") - .birthYear(1888).deathYear(1965) + .birthYear(1888) .personType(PersonType.PERSON).provisional(false).build(); Person result = personService.upsertBySourceRef(cmd); - assertThat(result.getBirthYear()).isEqualTo(1890); // human value kept - assertThat(result.getDeathYear()).isEqualTo(1965); // blank filled from canonical + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 3, 14)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY); + } + + @Test + void upsertBySourceRef_preservesMonthPrecisionDate_onReimport() { + Person handDated = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram") + .deathDate(LocalDate.of(1944, 11, 1)).deathDatePrecision(DatePrecision.MONTH).build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .deathYear(1945) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 1)); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.MONTH); + } + + @Test + void upsertBySourceRef_refreshesYearPrecisionDate_whenSpreadsheetYearChanges() { + // YEAR precision means "the importer's year" — a corrected spreadsheet year wins. + Person yearOnly = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram") + .birthDate(LocalDate.of(1890, 1, 1)).birthDatePrecision(DatePrecision.YEAR).build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(yearOnly)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .birthYear(1888) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1888, 1, 1)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR); + } + + @Test + void upsertBySourceRef_fillsEmptyDateAtYearPrecision_onReimport() { + Person noDates = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .deathYear(1965) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1)); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR); + } + + @Test + void upsertBySourceRef_keepsDatesEmpty_whenSpreadsheetHasNoYear() { + Person noDates = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isNull(); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + assertThat(result.getDeathDate()).isNull(); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + } + + @Test + void upsertBySourceRef_translatesYearToDate_onFirstImport() { + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .birthYear(1890).deathYear(1965) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR); + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1)); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR); } @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index 48483476..add77786 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -18,6 +18,9 @@ import org.raddatz.familienarchiv.document.DocumentRepository; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import org.raddatz.familienarchiv.document.DatePrecision; + +import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.Set; @@ -910,4 +913,61 @@ class PersonRepositoryTest { .setParameter(1, blockId).getSingleResult(); assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram"); } + + // ─── #773: PersonSummaryDTO year projection from birth_date/death_date ────── + + @Test + void findAllWithDocumentCount_derivesYearsFromDates_nullSafe() { + personRepository.save(Person.builder() + .firstName("Maria").lastName("Datiert") + .birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY) + .build()); + personRepository.save(Person.builder() + .firstName("Nora").lastName("Undatiert") + .build()); + entityManager.flush(); + + List all = personRepository.findAllWithDocumentCount(); + + PersonSummaryDTO dated = all.stream() + .filter(p -> "Datiert".equals(p.getLastName())).findFirst().orElseThrow(); + assertThat(dated.getBirthYear()).isEqualTo(1901); + assertThat(dated.getDeathYear()).isNull(); + PersonSummaryDTO undated = all.stream() + .filter(p -> "Undatiert".equals(p.getLastName())).findFirst().orElseThrow(); + assertThat(undated.getBirthYear()).isNull(); + assertThat(undated.getDeathYear()).isNull(); + } + + @Test + void searchWithDocumentCount_groupByPath_derivesYearsFromDates() { + personRepository.save(Person.builder() + .firstName("Herbert").lastName("Gruppiert") + .birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR) + .deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY) + .build()); + entityManager.flush(); + + List found = personRepository.searchWithDocumentCount("Gruppiert"); + + assertThat(found).hasSize(1); + assertThat(found.get(0).getBirthYear()).isEqualTo(1899); + assertThat(found.get(0).getDeathYear()).isEqualTo(1972); + } + + @Test + void findByFilter_derivesYearsFromDates() { + personRepository.save(Person.builder() + .firstName("Filtriert").lastName("Person") + .birthDate(LocalDate.of(1920, 1, 1)).birthDatePrecision(DatePrecision.YEAR) + .build()); + entityManager.flush(); + + List found = personRepository.findByFilter( + null, null, null, null, false, "Filtriert", 10, 0); + + assertThat(found).hasSize(1); + assertThat(found.get(0).getBirthYear()).isEqualTo(1920); + assertThat(found.get(0).getDeathYear()).isNull(); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index 9cabe1ce..161e359c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -8,7 +8,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.person.PersonNameAliasDTO; import org.raddatz.familienarchiv.person.PersonSummaryDTO; import org.raddatz.familienarchiv.person.PersonUpdateDTO; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonNameAlias; import org.raddatz.familienarchiv.person.PersonNameAliasType; @@ -17,6 +19,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasRepository; import org.raddatz.familienarchiv.person.PersonRepository; import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -241,27 +244,49 @@ class PersonServiceTest { PersonUpdateDTO dto = new PersonUpdateDTO(); dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria"); - dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes"); + dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.DAY); + dto.setDeathDate(LocalDate.of(1975, 11, 2)); dto.setDeathDatePrecision(DatePrecision.DAY); + dto.setNotes("Some notes"); Person result = personService.createPerson(dto); assertThat(result.getFirstName()).isEqualTo("Maria"); assertThat(result.getLastName()).isEqualTo("Raddatz"); assertThat(result.getAlias()).isEqualTo("Oma Maria"); - assertThat(result.getBirthYear()).isEqualTo(1901); - assertThat(result.getDeathYear()).isEqualTo(1975); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY); + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1975, 11, 2)); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY); assertThat(result.getNotes()).isEqualTo("Some notes"); } @Test - void createPerson_dto_yearValidationFires_whenBirthYearNegative() { + void createPerson_dto_rejectsDateWithUnknownPrecision() { PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1); + dto.setFirstName("Anna"); dto.setLastName("Test"); + dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.UNKNOWN); assertThatThrownBy(() -> personService.createPerson(dto)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .satisfies(e -> { + assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION); + assertThat(((DomainException) e).getStatus().value()).isEqualTo(400); + }); + } + + @Test + void createPerson_dto_treatsNullPrecisionWithNullDateAsUnknown() { + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.PERSON); + + Person result = personService.createPerson(dto); + + assertThat(result.getBirthDate()).isNull(); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + assertThat(result.getDeathDate()).isNull(); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); } @Test @@ -600,114 +625,135 @@ class PersonServiceTest { assertThat(result.getNotes()).isNull(); } - // ─── updatePerson (birth/death years) ──────────────────────────────────── + // ─── updatePerson (birth/death dates) ──────────────────────────────────── @Test - void updatePerson_persistsBirthAndDeathYear() { + void updatePerson_persistsBirthAndDeathDateWithPrecision() { UUID id = UUID.randomUUID(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965); + dto.setFirstName("Anna"); dto.setLastName("Alt"); + dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR); + dto.setDeathDate(LocalDate.of(1965, 6, 12)); dto.setDeathDatePrecision(DatePrecision.DAY); Person result = personService.updatePerson(id, dto); - assertThat(result.getBirthYear()).isEqualTo(1890); - assertThat(result.getDeathYear()).isEqualTo(1965); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR); + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 6, 12)); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY); } @Test - void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() { + void updatePerson_throwsBirthAfterDeath_whenBirthDateAfterDeathDate() { UUID id = UUID.randomUUID(); PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950); + dto.setFirstName("Anna"); dto.setLastName("Alt"); + dto.setBirthDate(LocalDate.of(1970, 5, 1)); dto.setBirthDatePrecision(DatePrecision.DAY); + dto.setDeathDate(LocalDate.of(1950, 5, 1)); dto.setDeathDatePrecision(DatePrecision.DAY); assertThatThrownBy(() -> personService.updatePerson(id, dto)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .satisfies(e -> { + assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.BIRTH_AFTER_DEATH); + assertThat(((DomainException) e).getStatus().value()).isEqualTo(400); + }); } @Test - void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() { - // Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw + void updatePerson_throwsBirthAfterDeath_onMixedPrecisionLateBirthday() { + // Known limitation (#773): DAY-precision birth late in the death's YEAR-precision year + // compares against the year's backfilled Jan 1st and is rejected. The error message + // carries the workaround hint via the BIRTH_AFTER_DEATH i18n key. + UUID id = UUID.randomUUID(); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Alt"); + dto.setBirthDate(LocalDate.of(1901, 11, 15)); dto.setBirthDatePrecision(DatePrecision.DAY); + dto.setDeathDate(LocalDate.of(1901, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR); + assertThatThrownBy(() -> personService.updatePerson(id, dto)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.BIRTH_AFTER_DEATH); + } + + @Test + void updatePerson_doesNotThrow_whenBirthDateSetButDeathDateNull() { UUID id = UUID.randomUUID(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null); + dto.setFirstName("Anna"); dto.setLastName("Alt"); + dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR); Person result = personService.updatePerson(id, dto); - assertThat(result.getBirthYear()).isEqualTo(1890); - assertThat(result.getDeathYear()).isNull(); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1)); + assertThat(result.getDeathDate()).isNull(); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); } @Test - void updatePerson_allowsSameYear() { + void updatePerson_allowsEqualBirthAndDeathDate() { UUID id = UUID.randomUUID(); Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); when(personRepository.findById(id)).thenReturn(Optional.of(person)); when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900); + dto.setFirstName("Anna"); dto.setLastName("Alt"); + dto.setBirthDate(LocalDate.of(1900, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR); + dto.setDeathDate(LocalDate.of(1900, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR); Person result = personService.updatePerson(id, dto); - assertThat(result.getBirthYear()).isEqualTo(1900); - assertThat(result.getDeathYear()).isEqualTo(1900); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1)); + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1900, 1, 1)); } - // ─── Phase 1.3: Year range bounds (> 0) ────────────────────────────────── + // ─── Date/precision coherence (V76 CHECK constraint mirror) ───────────── @Test - void updatePerson_throwsBadRequest_whenBirthYearIsZero() { + void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionUnknown() { UUID id = UUID.randomUUID(); PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0); + dto.setFirstName("Anna"); dto.setLastName("Alt"); + dto.setDeathDate(LocalDate.of(1944, 11, 2)); dto.setDeathDatePrecision(DatePrecision.UNKNOWN); assertThatThrownBy(() -> personService.updatePerson(id, dto)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .satisfies(e -> { + assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION); + assertThat(((DomainException) e).getStatus().value()).isEqualTo(400); + }); } @Test - void updatePerson_throwsBadRequest_whenBirthYearIsNegative() { + void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionNull() { UUID id = UUID.randomUUID(); PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5); + dto.setFirstName("Anna"); dto.setLastName("Alt"); + dto.setBirthDate(LocalDate.of(1901, 3, 14)); assertThatThrownBy(() -> personService.updatePerson(id, dto)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_DATE_PRECISION); } @Test - void updatePerson_throwsBadRequest_whenDeathYearIsZero() { + void updatePerson_throwsInvalidDatePrecision_whenPrecisionSetWithoutDate() { UUID id = UUID.randomUUID(); PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0); + dto.setFirstName("Anna"); dto.setLastName("Alt"); + dto.setBirthDatePrecision(DatePrecision.DAY); assertThatThrownBy(() -> personService.updatePerson(id, dto)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); - } - - @Test - void updatePerson_throwsBadRequest_whenDeathYearIsNegative() { - UUID id = UUID.randomUUID(); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10); - assertThatThrownBy(() -> personService.updatePerson(id, dto)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(400); + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_DATE_PRECISION); } // ─── findCorrespondents ────────────────────────────────────────────────── -- 2.49.1 From d0895412d82226edbffa73c2f19d3513ec8de3e3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 18:03:20 +0200 Subject: [PATCH 04/16] chore(api): regenerate TypeScript types for Person date fields Person gains birthDate/deathDate + required precision enums; PersonSummaryDTO, PersonNodeDTO, and RelationshipDTO keep derived integer years. familyForest/buildLayout tests still pass. Co-Authored-By: Claude Fable 5 --- frontend/src/lib/generated/api.ts | 38 +++++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 7c33f707..5c3f05a3 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1714,10 +1714,14 @@ export interface components { lastName?: string; alias?: string; notes?: string; - /** Format: int32 */ - birthYear?: number; - /** Format: int32 */ - deathYear?: number; + /** Format: date */ + birthDate?: string; + /** @enum {string} */ + birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + deathDate?: string; + /** @enum {string} */ + deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; /** Format: int32 */ generation?: number; }; @@ -1731,10 +1735,14 @@ export interface components { personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP"; alias?: string; notes?: string; - /** Format: int32 */ - birthYear?: number; - /** Format: int32 */ - deathYear?: number; + /** Format: date */ + birthDate?: string; + /** @enum {string} */ + birthDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + deathDate?: string; + /** @enum {string} */ + deathDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; /** Format: int32 */ generation?: number; familyMember: boolean; @@ -2373,13 +2381,13 @@ export interface components { documentCount?: number; alias?: string; notes?: string; + personType?: string; + familyMember?: boolean; + provisional?: boolean; /** Format: int32 */ birthYear?: number; /** Format: int32 */ deathYear?: number; - provisional?: boolean; - personType?: string; - familyMember?: boolean; }; InferredRelationshipWithPersonDTO: { person: components["schemas"]["PersonNodeDTO"]; @@ -2476,14 +2484,14 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ numberOfElements?: number; empty?: boolean; @@ -2673,7 +2681,7 @@ export interface components { }; ActivityFeedItemDTO: { /** @enum {string} */ - kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED"; + kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "DOCUMENT_DELETED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED"; actor?: components["schemas"]["ActivityActorDTO"]; /** Format: uuid */ documentId: string; @@ -5541,7 +5549,7 @@ export interface operations { query?: { limit?: number; /** @description Filter by audit kinds; omit for all rollup-eligible kinds */ - kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[]; + kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "DOCUMENT_DELETED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[]; }; header?: never; path?: never; -- 2.49.1 From c41c69d0d1da3d406c5b51552ff117f2245a95f7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 18:06:16 +0200 Subject: [PATCH 05/16] feat(person): formatLifeDateRange takes date + precision, delegates to formatDocumentDate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New formatLifeDate single-date helper carries no glyph so cards can wrap * / † in aria-hidden spans. Missing precision falls back to YEAR. Co-Authored-By: Claude Fable 5 --- .../src/lib/person/personLifeDates.spec.ts | 117 +++++++++++++++--- frontend/src/lib/person/personLifeDates.ts | 56 ++++++--- 2 files changed, 143 insertions(+), 30 deletions(-) diff --git a/frontend/src/lib/person/personLifeDates.spec.ts b/frontend/src/lib/person/personLifeDates.spec.ts index cbe23ab8..934395e0 100644 --- a/frontend/src/lib/person/personLifeDates.spec.ts +++ b/frontend/src/lib/person/personLifeDates.spec.ts @@ -1,24 +1,113 @@ -import { describe, it, expect } from 'vitest'; -import { formatLifeDateRange } from './personLifeDates'; +import { describe, expect, it } from 'vitest'; +import { formatLifeDate, formatLifeDateRange } from './personLifeDates'; +// Delegates all precision rendering to formatDocumentDate — these tests pin the +// composition (glyphs, dash, empty sides) and one rendering per precision so a +// regression in the delegation is caught here, not on a person card. describe('formatLifeDateRange', () => { - it('returns both dates when birth and death year are given', () => { - expect(formatLifeDateRange(1882, 1944)).toBe('* 1882 – † 1944'); + describe('both dates (de default)', () => { + it('renders DAY precision as full dates', () => { + expect(formatLifeDateRange('1901-03-14', 'DAY', '1944-11-02', 'DAY')).toBe( + '* 14. März 1901 – † 2. November 1944' + ); + }); + + it('renders MONTH precision as month + year', () => { + expect(formatLifeDateRange('1901-03-01', 'MONTH', '1944-11-01', 'MONTH')).toBe( + '* März 1901 – † November 1944' + ); + }); + + it('renders YEAR precision as bare years', () => { + expect(formatLifeDateRange('1901-01-01', 'YEAR', '1944-01-01', 'YEAR')).toBe( + '* 1901 – † 1944' + ); + }); + + it('renders mixed precisions per side', () => { + expect(formatLifeDateRange('1901-03-14', 'DAY', '1944-01-01', 'YEAR')).toBe( + '* 14. März 1901 – † 1944' + ); + }); + + it('renders APPROX precision with the ca. prefix (legacy imports)', () => { + expect(formatLifeDateRange('1901-01-01', 'APPROX', '1944-01-01', 'APPROX')).toBe( + '* ca. 1901 – † ca. 1944' + ); + }); }); - it('returns only birth year when only birthYear is given', () => { - expect(formatLifeDateRange(1882, undefined)).toBe('* 1882'); + describe('single sides and empty states', () => { + it('renders birth only without dash or dagger', () => { + expect(formatLifeDateRange('1901-03-14', 'DAY', null, null)).toBe('* 14. März 1901'); + }); + + it('renders death only without dash or asterisk', () => { + expect(formatLifeDateRange(null, null, '1944-11-02', 'DAY')).toBe('† 2. November 1944'); + }); + + it('renders YEAR birth only', () => { + expect(formatLifeDateRange('1882-01-01', 'YEAR', null, null)).toBe('* 1882'); + }); + + it('renders APPROX death only', () => { + expect(formatLifeDateRange(null, null, '1944-01-01', 'APPROX')).toBe('† ca. 1944'); + }); + + it('returns empty string when both dates are null', () => { + expect(formatLifeDateRange(null, null, null, null)).toBe(''); + }); + + it('returns empty string when both dates are null even with UNKNOWN precisions', () => { + expect(formatLifeDateRange(null, 'UNKNOWN', null, 'UNKNOWN')).toBe(''); + }); + + it('falls back to YEAR rendering when a precision is missing', () => { + expect(formatLifeDateRange('1901-01-01', null, null, null)).toBe('* 1901'); + }); }); - it('returns only death year when only deathYear is given', () => { - expect(formatLifeDateRange(undefined, 1944)).toBe('† 1944'); - }); + describe('locales (German-month-leak guard)', () => { + it('renders DAY precision in English', () => { + expect(formatLifeDateRange('1901-03-14', 'DAY', null, null, 'en')).toBe('* March 14, 1901'); + }); - it('returns empty string when neither year is given', () => { - expect(formatLifeDateRange(undefined, undefined)).toBe(''); - }); + it('renders MONTH precision in English', () => { + expect(formatLifeDateRange('1901-03-01', 'MONTH', null, null, 'en')).toBe('* March 1901'); + }); - it('returns empty string when both are null', () => { - expect(formatLifeDateRange(null, null)).toBe(''); + it('renders DAY precision in Spanish', () => { + expect(formatLifeDateRange('1901-03-14', 'DAY', null, null, 'es')).toBe( + '* 14 de marzo de 1901' + ); + }); + + it('renders MONTH precision in Spanish', () => { + expect(formatLifeDateRange('1901-03-01', 'MONTH', null, null, 'es')).toBe('* marzo de 1901'); + }); + }); +}); + +// Single-date helper for components that must keep the * / † glyphs in their own +// aria-hidden markup (PersonCard, PersonHoverCard) instead of in the string. +describe('formatLifeDate', () => { + it('renders a DAY-precision date without any glyph', () => { + expect(formatLifeDate('1901-03-14', 'DAY')).toBe('14. März 1901'); + }); + + it('renders an APPROX-precision date (legacy imports)', () => { + expect(formatLifeDate('1901-01-01', 'APPROX')).toBe('ca. 1901'); + }); + + it('falls back to YEAR rendering when precision is missing', () => { + expect(formatLifeDate('1901-01-01', null)).toBe('1901'); + }); + + it('returns empty string for a null date', () => { + expect(formatLifeDate(null, 'DAY')).toBe(''); + }); + + it('renders in the requested locale', () => { + expect(formatLifeDate('1901-03-14', 'DAY', 'en')).toBe('March 14, 1901'); }); }); diff --git a/frontend/src/lib/person/personLifeDates.ts b/frontend/src/lib/person/personLifeDates.ts index 4adbb2fa..c6d72adc 100644 --- a/frontend/src/lib/person/personLifeDates.ts +++ b/frontend/src/lib/person/personLifeDates.ts @@ -1,20 +1,44 @@ +import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; + /** - * Formats the life date range for a person. - * Examples: - * * 1882 – † 1944 (both) - * * 1882 (birth only) - * † 1944 (death only) - * "" (neither) + * Formats one life date (birth or death) at the precision the data claims, + * delegating all rendering to {@link formatDocumentDate}. Returns '' for a + * missing date. Carries no * / † glyph — components that need the glyphs wrap + * them in their own `aria-hidden` markup so screen readers only hear the date. + * + * A missing precision falls back to YEAR: pre-V76 rows only knew a year, and + * a bare year is the only safe rendering for a date without precision metadata. */ -export function formatLifeDateRange(birthYear?: number | null, deathYear?: number | null): string { - if (birthYear && deathYear) { - return `* ${birthYear} – † ${deathYear}`; +export function formatLifeDate( + date: string | null | undefined, + precision: DatePrecision | null | undefined, + locale?: string +): string { + if (!date) { + return ''; } - if (birthYear) { - return `* ${birthYear}`; - } - if (deathYear) { - return `† ${deathYear}`; - } - return ''; + return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale); +} + +/** + * Formats the full life date range as plain text, e.g. for dropdown subtitles. + * Examples: + * * 14. März 1901 – † 2. November 1944 (both, DAY precision) + * * 1882 (birth only, YEAR precision) + * † ca. 1944 (death only, APPROX precision) + * "" (neither) + */ +export function formatLifeDateRange( + birthDate: string | null | undefined, + birthDatePrecision: DatePrecision | null | undefined, + deathDate: string | null | undefined, + deathDatePrecision: DatePrecision | null | undefined, + locale?: string +): string { + const birth = birthDate ? `* ${formatLifeDate(birthDate, birthDatePrecision, locale)}` : null; + const death = deathDate ? `† ${formatLifeDate(deathDate, deathDatePrecision, locale)}` : null; + if (birth && death) { + return `${birth} – ${death}`; + } + return birth ?? death ?? ''; } -- 2.49.1 From 4dcf8e2242bebf9a7899822a7cd76355a0f001ff Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 18:14:20 +0200 Subject: [PATCH 06/16] feat(person): render precise life dates on cards, hover card, and mention dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards compose aria-hidden * / † glyphs in markup so screen readers only announce the dates; PersonSummaryDTO list card stays year-shaped by design (ADR-039). MentionDropdown subtitle wraps instead of truncating so DAY-precision ranges fit at 320px. Co-Authored-By: Claude Fable 5 --- .../TranscriptionReadView.svelte.test.ts | 6 +- frontend/src/lib/person/PersonCard.svelte | 12 ++- .../src/lib/person/PersonCard.svelte.test.ts | 21 ++++++ .../src/lib/person/PersonHoverCard.svelte | 24 ++++-- .../lib/person/PersonHoverCard.svelte.spec.ts | 72 ++++++++++++++++-- .../shared/discussion/MentionDropdown.svelte | 14 +++- .../discussion/MentionDropdown.svelte.test.ts | 30 +++++++- .../PersonMentionEditor.svelte.spec.ts | 9 ++- .../src/routes/persons/[id]/PersonCard.svelte | 24 ++++-- .../persons/[id]/PersonCard.svelte.test.ts | 75 ++++++++++++++++++- 10 files changed, 254 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/document/transcription/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/document/transcription/TranscriptionReadView.svelte.test.ts index ce9f2985..9e2b2d06 100644 --- a/frontend/src/lib/document/transcription/TranscriptionReadView.svelte.test.ts +++ b/frontend/src/lib/document/transcription/TranscriptionReadView.svelte.test.ts @@ -335,8 +335,10 @@ describe('TranscriptionReadView — person-mention rendering', () => { displayName: 'Auguste Raddatz', personType: 'PERSON', familyMember: true, - birthYear: 1882, - deathYear: 1944 + birthDate: '1882-01-01', + birthDatePrecision: 'YEAR', + deathDate: '1944-01-01', + deathDatePrecision: 'YEAR' }) }); }) diff --git a/frontend/src/lib/person/PersonCard.svelte b/frontend/src/lib/person/PersonCard.svelte index f1c38093..8ac098ee 100644 --- a/frontend/src/lib/person/PersonCard.svelte +++ b/frontend/src/lib/person/PersonCard.svelte @@ -1,6 +1,5 @@

@@ -91,10 +97,16 @@ let {

„{person.alias}"

{/if} - - {#if person.birthYear || person.deathYear} + + {#if birthText || deathText}

- {formatLifeDateRange(person.birthYear, person.deathYear)} + {#if birthText} + {birthText} + {/if} + {#if birthText && deathText}–{/if} + {#if deathText} + {deathText} + {/if}

{:else}
diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts b/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts index b2c7c4fc..efb63660 100644 --- a/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts +++ b/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts @@ -82,15 +82,84 @@ describe('PersonCard', () => { await expect.element(page.getByText(/Annerl/)).toBeVisible(); }); - it('renders the life-date range when birthYear or deathYear are present', async () => { + it('renders DAY-precision life dates as full localized dates', async () => { render(PersonCard, { props: { - person: { ...basePerson, birthYear: 1899, deathYear: 1972 }, + person: { + ...basePerson, + birthDate: '1901-03-14', + birthDatePrecision: 'DAY' as const, + deathDate: '1944-11-02', + deathDatePrecision: 'DAY' as const + }, canWrite: false } }); - await expect.element(page.getByText(/1899/)).toBeVisible(); + await expect.element(page.getByText(/14\. März 1901/)).toBeVisible(); + await expect.element(page.getByText(/2\. November 1944/)).toBeVisible(); + }); + + it('wraps the * and † glyphs in aria-hidden spans', async () => { + const { container } = render(PersonCard, { + props: { + person: { + ...basePerson, + birthDate: '1901-03-14', + birthDatePrecision: 'DAY' as const, + deathDate: '1944-11-02', + deathDatePrecision: 'DAY' as const + }, + canWrite: false + } + }); + + const hidden = [...container.querySelectorAll('span[aria-hidden="true"]')].map((el) => + el.textContent?.trim() + ); + expect(hidden).toContain('*'); + expect(hidden).toContain('†'); + }); + + it('renders birth-only without dash or dagger', async () => { + const { container } = render(PersonCard, { + props: { + person: { + ...basePerson, + birthDate: '1901-03-14', + birthDatePrecision: 'DAY' as const + }, + canWrite: false + } + }); + + await expect.element(page.getByText(/14\. März 1901/)).toBeVisible(); + expect(container.textContent).not.toContain('–'); + expect(container.textContent).not.toContain('†'); + }); + + it('renders APPROX-precision legacy dates with the ca. prefix', async () => { + render(PersonCard, { + props: { + person: { + ...basePerson, + birthDate: '1882-01-01', + birthDatePrecision: 'APPROX' as const + }, + canWrite: false + } + }); + + await expect.element(page.getByText(/ca\. 1882/)).toBeVisible(); + }); + + it('renders no life-date line when both dates are missing', async () => { + const { container } = render(PersonCard, { + props: { person: basePerson, canWrite: false } + }); + + expect(container.textContent).not.toContain('*'); + expect(container.textContent).not.toContain('†'); }); it('renders the notes section when notes are provided', async () => { -- 2.49.1 From 9664a83dae2bcf4d69048fa2c3474bcf5b245c2c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 18:20:18 +0200 Subject: [PATCH 07/16] feat(person): date + precision controls on person new/edit forms New PersonLifeDateField (German date input + hidden ISO + DAY/MONTH/YEAR precision select, min-h-44px, sm: side-by-side) used for birth and death in both forms. Legacy APPROX precision seeds the select as YEAR so an untouched save never claims DAY. Server actions send date+precision pairs or omit both; obsolete year i18n keys removed, 9 form keys added. Co-Authored-By: Claude Fable 5 --- frontend/messages/de.json | 12 ++- frontend/messages/en.json | 12 ++- frontend/messages/es.json | 12 ++- .../src/lib/person/PersonLifeDateField.svelte | 96 +++++++++++++++++++ frontend/src/lib/person/person-validation.ts | 6 +- .../routes/persons/[id]/edit/+page.server.ts | 20 ++-- .../persons/[id]/edit/PersonEditForm.svelte | 41 +++----- .../[id]/edit/PersonEditForm.svelte.test.ts | 92 +++++++++++++++--- .../src/routes/persons/new/+page.server.ts | 21 ++-- frontend/src/routes/persons/new/+page.svelte | 35 +++---- 10 files changed, 258 insertions(+), 89 deletions(-) create mode 100644 frontend/src/lib/person/PersonLifeDateField.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8cc65867..eda6db62 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -174,12 +174,18 @@ "person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.", "person_label_notes": "Notizen", "person_placeholder_notes": "Biographische Hinweise, Besonderheiten…", - "person_label_birth_year": "Geburtsjahr", - "person_label_death_year": "Todesjahr", + "person_label_birth_date": "Geburtsdatum", + "person_label_death_date": "Sterbedatum", + "person_label_birth_date_precision": "Genauigkeit", + "person_label_death_date_precision": "Genauigkeit", + "person_precision_hint": "Wie genau ist dieses Datum bekannt?", + "person_precision_day": "Genaues Datum (Tag)", + "person_precision_month": "Monat bekannt", + "person_precision_year": "Nur Jahreszahl", + "person_date_placeholder_hint": "Leer lassen, wenn unbekannt", "person_label_generation": "Generation", "person_option_generation_unset": "(keine)", "person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)", - "person_placeholder_year": "z.B. 1923", "person_year_error": "Bitte eine vierstellige Jahreszahl eingeben", "person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen", "person_docs_heading": "Gesendete Dokumente", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9f640189..1e73602b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -174,12 +174,18 @@ "person_merge_warning": "Warning: This action cannot be undone.", "person_label_notes": "Notes", "person_placeholder_notes": "Biographical notes, remarks…", - "person_label_birth_year": "Birth year", - "person_label_death_year": "Death year", + "person_label_birth_date": "Date of birth", + "person_label_death_date": "Date of death", + "person_label_birth_date_precision": "Precision", + "person_label_death_date_precision": "Precision", + "person_precision_hint": "How precisely is this date known?", + "person_precision_day": "Exact date (day)", + "person_precision_month": "Month known", + "person_precision_year": "Year only", + "person_date_placeholder_hint": "Leave empty if unknown", "person_label_generation": "Generation", "person_option_generation_unset": "(none)", "person_hint_generation": "Generation within the family (G 0 = oldest generation)", - "person_placeholder_year": "e.g. 1923", "person_year_error": "Please enter a four-digit year", "person_years_error_order": "Birth year must be before death year", "person_docs_heading": "Sent documents", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index d44b21a6..a4267fd5 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -174,12 +174,18 @@ "person_merge_warning": "Atención: Esta acción no se puede deshacer.", "person_label_notes": "Notas", "person_placeholder_notes": "Notas biográficas, observaciones…", - "person_label_birth_year": "Año de nacimiento", - "person_label_death_year": "Año de fallecimiento", + "person_label_birth_date": "Fecha de nacimiento", + "person_label_death_date": "Fecha de defunción", + "person_label_birth_date_precision": "Precisión", + "person_label_death_date_precision": "Precisión", + "person_precision_hint": "¿Con qué precisión se conoce esta fecha?", + "person_precision_day": "Fecha exacta (día)", + "person_precision_month": "Mes conocido", + "person_precision_year": "Solo año", + "person_date_placeholder_hint": "Dejar vacío si es desconocido", "person_label_generation": "Generación", "person_option_generation_unset": "(ninguna)", "person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)", - "person_placeholder_year": "p.ej. 1923", "person_year_error": "Introduzca un año de cuatro dígitos", "person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento", "person_docs_heading": "Documentos enviados", diff --git a/frontend/src/lib/person/PersonLifeDateField.svelte b/frontend/src/lib/person/PersonLifeDateField.svelte new file mode 100644 index 00000000..53a8e04f --- /dev/null +++ b/frontend/src/lib/person/PersonLifeDateField.svelte @@ -0,0 +1,96 @@ + + +
+ + {legend} + +
+
+ + +
+
+ +
+
+

+ {m.person_precision_hint()} · {m.person_date_placeholder_hint()} +

+
diff --git a/frontend/src/lib/person/person-validation.ts b/frontend/src/lib/person/person-validation.ts index 38823859..00bcfdf8 100644 --- a/frontend/src/lib/person/person-validation.ts +++ b/frontend/src/lib/person/person-validation.ts @@ -9,8 +9,10 @@ export type PersonFormData = { firstName?: string | null; lastName: string; alias?: string | null; - birthYear?: number | null; - deathYear?: number | null; + birthDate?: string | null; + birthDatePrecision?: string | null; + deathDate?: string | null; + deathDatePrecision?: string | null; generation?: number | null; notes?: string | null; }; diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts index 91461f78..460d95f7 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.server.ts +++ b/frontend/src/routes/persons/[id]/edit/+page.server.ts @@ -1,6 +1,7 @@ import { error, fail, redirect } from '@sveltejs/kit'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; +import type { DatePrecision } from '$lib/shared/utils/documentDate'; import { normalizePersonType, validatePersonFields, @@ -47,10 +48,17 @@ export const actions = { const lastName = formData.get('lastName')?.toString().trim(); const alias = formData.get('alias')?.toString().trim() || undefined; const notes = formData.get('notes')?.toString().trim() || undefined; - const birthYearStr = formData.get('birthYear')?.toString().trim(); - const deathYearStr = formData.get('deathYear')?.toString().trim(); - const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined; - const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined; + // Empty date input → omit date AND precision: the backend normalises the + // absent pair to null/UNKNOWN, and a lone precision would fail the + // coherence check (INVALID_DATE_PRECISION). + const birthDate = formData.get('birthDate')?.toString().trim() || undefined; + const birthDatePrecision = birthDate + ? (formData.get('birthDatePrecision')?.toString() as DatePrecision) + : undefined; + const deathDate = formData.get('deathDate')?.toString().trim() || undefined; + const deathDatePrecision = deathDate + ? (formData.get('deathDatePrecision')?.toString() as DatePrecision) + : undefined; // Must NOT use the conditional-spread idiom for generation: G 0 is a // valid family-tree-root value. The key always travels in the body so // an explicit clear (empty option) reaches the backend as null. @@ -73,8 +81,8 @@ export const actions = { lastName, ...(alias ? { alias } : {}), ...(notes ? { notes } : {}), - ...(birthYear ? { birthYear } : {}), - ...(deathYear ? { deathYear } : {}), + ...(birthDate ? { birthDate, birthDatePrecision } : {}), + ...(deathDate ? { deathDate, deathDatePrecision } : {}), generation } }); diff --git a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte index 3a746550..c4b473c5 100644 --- a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte +++ b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte @@ -1,6 +1,7 @@ { const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement; expect(select.value).toBe(''); }); + + // ─── partial-date guard (#812 review) ──────────────────────────────────────── + + it('blocks submission while a stored birth date is partially edited (no silent clear)', async () => { + render(PersonEditForm, { props: { person: personPersonal } }); + + await userEvent.fill(page.getByLabelText(/^geburtsdatum$/i), '14.03.'); + + const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement; + expect(birthInput.checkValidity()).toBe(false); + await expect.element(page.getByText(/Bitte im Format TT\.MM\.JJJJ/)).toBeVisible(); + }); }); -- 2.49.1 From ef7d0f21826d5ed4b3747c02acb10f363f383d2c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 19:34:28 +0200 Subject: [PATCH 13/16] fix(import): degrade gracefully when canonical life dates conflict The canonical upsert path skips validateLifeDates, so a spreadsheet row with birth_year > death_year - or a preserved hand-entered birth date conflicting with a canonical death year - violated the V76 CHECK constraint at flush time and aborted the whole import batch with a raw 500. Resolve the pairs first and, on conflict, keep the person's stored life dates (empty for a new person), drop the canonical refresh, and log a WARN with the sourceRef (REQ-IMP-001: never abort the batch). Co-Authored-By: Claude Fable 5 --- .../familienarchiv/person/PersonService.java | 45 ++++++++++--- .../person/PersonImportUpsertTest.java | 66 +++++++++++++++++++ 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index 0608639b..e7a9ea1f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -301,8 +301,11 @@ public class PersonService { } private Person fromCanonical(PersonUpsertCommand cmd) { - DatePrecisionPair birth = yearPair(cmd.birthYear()); - DatePrecisionPair death = yearPair(cmd.deathYear()); + DatePrecisionPair none = new DatePrecisionPair(null, DatePrecision.UNKNOWN); + LifeDates dates = degradeIfConflicting( + yearPair(cmd.birthYear()), yearPair(cmd.deathYear()), none, none, cmd.sourceRef()); + DatePrecisionPair birth = dates.birth(); + DatePrecisionPair death = dates.death(); Person person = personRepository.save(Person.builder() .sourceRef(cmd.sourceRef()) .firstName(blankToNull(cmd.firstName())) @@ -334,14 +337,16 @@ public class PersonService { existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName())); existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName())); existing.setNotes(preferHuman(existing.getNotes(), cmd.notes())); - DatePrecisionPair birth = preferHumanDate( - existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()); - existing.setBirthDate(birth.date()); - existing.setBirthDatePrecision(birth.precision()); - DatePrecisionPair death = preferHumanDate( - existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()); - existing.setDeathDate(death.date()); - existing.setDeathDatePrecision(death.precision()); + LifeDates dates = degradeIfConflicting( + preferHumanDate(existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()), + preferHumanDate(existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()), + new DatePrecisionPair(existing.getBirthDate(), existing.getBirthDatePrecision()), + new DatePrecisionPair(existing.getDeathDate(), existing.getDeathDatePrecision()), + cmd.sourceRef()); + existing.setBirthDate(dates.birth().date()); + existing.setBirthDatePrecision(dates.birth().precision()); + existing.setDeathDate(dates.death().date()); + existing.setDeathDatePrecision(dates.death().precision()); existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation())); if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) { existing.setPersonType(cmd.personType()); @@ -371,6 +376,26 @@ public class PersonService { // Date + precision travel as one value so they can never go out of sync (ADR-039). record DatePrecisionPair(LocalDate date, DatePrecision precision) {} + record LifeDates(DatePrecisionPair birth, DatePrecisionPair death) {} + + // The canonical path skips validateLifeDates (the form-only guard), so a conflicting + // resolved pair would hit chk_person_birth_before_death at flush time and abort the + // whole import batch with a raw 500. Degrade instead (REQ-IMP-001: never abort the + // batch): keep the person's stored life dates — empty for a new person — and drop the + // conflicting canonical refresh. A hand-entered side is preserved by construction, + // since preferHumanDate returned it verbatim and it equals the stored value; two + // stored values can never conflict with each other (they already satisfied the CHECK). + static LifeDates degradeIfConflicting(DatePrecisionPair birth, DatePrecisionPair death, + DatePrecisionPair existingBirth, DatePrecisionPair existingDeath, + String sourceRef) { + if (birth.date() == null || death.date() == null || !birth.date().isAfter(death.date())) { + return new LifeDates(birth, death); + } + log.warn("Conflicting canonical life dates for {}: birth {} is after death {} — keeping stored values", + sourceRef, birth.date(), death.date()); + return new LifeDates(existingBirth, existingDeath); + } + // preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than // the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a // YEAR-precision or absent date is refreshed from the canonical year. diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java index b14b2691..198c3ce6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java @@ -297,4 +297,70 @@ class PersonImportUpsertTest { assertThat(result.getGeneration()).isEqualTo(3); } + + // ─── conflicting canonical life dates degrade instead of hitting the DB CHECK ── + // (chk_person_birth_before_death would abort the whole batch — REQ-IMP-001) + + @Test + void upsertBySourceRef_dropsBothDates_whenCanonicalBirthAfterDeath_newPerson() { + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .birthYear(1950).deathYear(1949) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isNull(); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + assertThat(result.getDeathDate()).isNull(); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + } + + @Test + void upsertBySourceRef_keepsHandEnteredBirth_andDropsConflictingCanonicalDeath() { + // A human entered an exact birthday; the spreadsheet's death year lies before it. + // The hand-entered side must survive, the conflicting canonical refresh is dropped. + Person handDated = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram") + .birthDate(LocalDate.of(1950, 6, 1)).birthDatePrecision(DatePrecision.DAY).build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .deathYear(1949) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1950, 6, 1)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY); + assertThat(result.getDeathDate()).isNull(); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + } + + @Test + void upsertBySourceRef_keepsExistingYearDates_whenCanonicalRefreshConflicts() { + Person existing = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram") + .birthDate(LocalDate.of(1900, 1, 1)).birthDatePrecision(DatePrecision.YEAR) + .deathDate(LocalDate.of(1980, 1, 1)).deathDatePrecision(DatePrecision.YEAR).build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").lastName("Cram") + .birthYear(1990).deathYear(1985) + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1)); + assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR); + assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1980, 1, 1)); + assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR); + } } -- 2.49.1 From 2a9ae80f8142f4ea3c77cf9db2d26932713422bf Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 19:35:50 +0200 Subject: [PATCH 14/16] refactor(person): share yearOf between relationship services Co-Authored-By: Claude Fable 5 --- .../person/relationship/RelationshipInferenceService.java | 4 ++-- .../person/relationship/RelationshipService.java | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java index 96d7fa50..3226f1b6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipInferenceService.java @@ -97,8 +97,8 @@ public class RelationshipInferenceService { List path = shortestPaths.get(id); PersonNodeDTO node = new PersonNodeDTO( p.getId(), p.getDisplayName(), - p.getBirthDate() != null ? p.getBirthDate().getYear() : null, - p.getDeathDate() != null ? p.getDeathDate().getYear() : null, + RelationshipService.yearOf(p.getBirthDate()), + RelationshipService.yearOf(p.getDeathDate()), p.getGeneration(), p.isFamilyMember()); out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size())); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java index e764da04..51b312c6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java @@ -158,8 +158,9 @@ public class RelationshipService { } // Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe - // for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). - private static Integer yearOf(LocalDate date) { + // for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). Package-private + // so RelationshipInferenceService shares the same derivation. + static Integer yearOf(LocalDate date) { return date != null ? date.getYear() : null; } -- 2.49.1 From 11efb44e7fa6f7050c60fe540cfefb656ade65bb Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 19:36:24 +0200 Subject: [PATCH 15/16] fix(i18n): add trailing period to error_invalid_date_precision Co-Authored-By: Claude Fable 5 --- frontend/messages/de.json | 2 +- frontend/messages/en.json | 2 +- frontend/messages/es.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index eda6db62..9b607077 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -650,7 +650,7 @@ "error_invalid_person_type": "Der angegebene Personentyp ist ungültig.", "error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.", "error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.", - "error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein", + "error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.", "validation_last_name_required": "Nachname ist Pflichtfeld.", "validation_first_name_required": "Vorname ist Pflichtfeld.", "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1e73602b..7bb8827b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -650,7 +650,7 @@ "error_invalid_person_type": "The specified person type is not valid.", "error_invalid_date_range": "The end date must not be before the start date.", "error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.", - "error_invalid_date_precision": "Date and precision do not match", + "error_invalid_date_precision": "Date and precision do not match.", "validation_last_name_required": "Last name is required.", "validation_first_name_required": "First name is required.", "error_ocr_service_unavailable": "The OCR service is not available.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index a4267fd5..1595954c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -650,7 +650,7 @@ "error_invalid_person_type": "El tipo de persona especificado no es válido.", "error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.", "error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.", - "error_invalid_date_precision": "La fecha y la precisión no coinciden", + "error_invalid_date_precision": "La fecha y la precisión no coinciden.", "validation_last_name_required": "El apellido es obligatorio.", "validation_first_name_required": "El nombre es obligatorio.", "error_ocr_service_unavailable": "El servicio OCR no está disponible.", -- 2.49.1 From 2fac687318a9256f9ea4664c7458bc0e344f0d00 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 19:37:11 +0200 Subject: [PATCH 16/16] docs(person): note YEAR seeding of legacy precisions in ADR-039 Co-Authored-By: Claude Fable 5 --- docs/adr/039-person-life-dates-localdate-precision.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/adr/039-person-life-dates-localdate-precision.md b/docs/adr/039-person-life-dates-localdate-precision.md index e21ebd6e..e4bba762 100644 --- a/docs/adr/039-person-life-dates-localdate-precision.md +++ b/docs/adr/039-person-life-dates-localdate-precision.md @@ -45,6 +45,13 @@ are semantically nonsensical for a birth or death, and `APPROX` is excluded from form to reduce cognitive load for the senior author audience. Legacy `APPROX` rows still render correctly (display delegates to `formatDocumentDate`). +The edit form seeds a stored non-offered precision (`APPROX`/`SEASON`/`RANGE`) into +the select as `YEAR`, so an untouched save coerces it to `YEAR` ("ca. 1944" becomes +"1944"). Accepted: nothing currently writes those precisions to persons (the form +offers DAY/MONTH/YEAR, the importer writes YEAR/UNKNOWN, V76 backfills YEAR), so the +case is only reachable via direct API writes — and seeding `YEAR` is strictly safer +than the alternative of silently claiming `DAY` precision. + ### 3. Derived-year pattern for backward-compatible DTOs `PersonNodeDTO` (Stammbaum) and `RelationshipDTO` keep `Integer birthYear/deathYear`, -- 2.49.1