diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DatePrecisionValidation.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DatePrecisionValidation.java new file mode 100644 index 00000000..7ddfd9df --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DatePrecisionValidation.java @@ -0,0 +1,42 @@ +package org.raddatz.familienarchiv.document; + +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; + +import java.time.LocalDate; + +/** + * Cross-field validation and normalization shared by every domain that stores a + * {@link LocalDate} + {@link DatePrecision} pair — a person's life dates (ADR-039 / V76) + * and a relationship's from/to dates (ADR-044 / V78). Kept out of {@link DatePrecision} + * itself because that enum is a frozen contract mirror of the import normalizer (ADR-025) + * and must carry no behaviour. + */ +public final class DatePrecisionValidation { + + private DatePrecisionValidation() {} + + /** + * Enforces the date ⇔ precision coherence the V76/V78 CHECK constraints also enforce: + * a date requires a non-{@code UNKNOWN} precision, and a non-{@code UNKNOWN} precision + * requires a date. Validated in-service so the caller gets a structured 400 instead of + * the database constraint's raw 500. + * + * @param side human-readable field label woven into the error message ("birth", "from", …) + */ + public static void requireCoherence(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 (date == null && precision != null && precision != DatePrecision.UNKNOWN) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION, + side + " date precision " + precision + " is set without a date"); + } + } + + /** A null precision means "no precision recorded" → {@link DatePrecision#UNKNOWN}. */ + public static DatePrecision normalize(DatePrecision precision) { + return precision == null ? DatePrecision.UNKNOWN : precision; + } +} 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 f51d2d47..a766a6b4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -18,6 +18,7 @@ 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.document.DatePrecisionValidation; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; @@ -448,41 +449,28 @@ public class PersonService { .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()) .notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()) .birthDate(dto.getBirthDate()) - .birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())) + .birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision())) .deathDate(dto.getDeathDate()) - .deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision())) + .deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision())) .generation(dto.getGeneration()) .build(); return personRepository.save(person); } // 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. + // user gets a structured ErrorCode instead of a raw constraint-violation 500. Coherence + // is shared with the relationship domain (DatePrecisionValidation); only the order check + // (and its BIRTH_AFTER_DEATH code) is life-date specific. private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision, LocalDate deathDate, DatePrecision deathPrecision) { - requireDatePrecisionCoherence(birthDate, birthPrecision, "birth"); - requireDatePrecisionCoherence(deathDate, deathPrecision, "death"); + DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth"); + DatePrecisionValidation.requireCoherence(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); } } - 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 (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) { @@ -499,9 +487,9 @@ public class PersonService { 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.setBirthDate(dto.getBirthDate()); - person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())); + person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision())); person.setDeathDate(dto.getDeathDate()); - person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision())); + person.setDeathDatePrecision(DatePrecisionValidation.normalize(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/relationship/RelationshipService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipService.java index c466bb92..545a1e78 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 @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.person.relationship; import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.DatePrecisionValidation; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; @@ -120,9 +121,9 @@ public class RelationshipService { .relatedPerson(relatedPerson) .relationType(dto.relationType()) .fromDate(dto.fromDate()) - .fromDatePrecision(normalizePrecision(dto.fromDatePrecision())) + .fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision())) .toDate(dto.toDate()) - .toDatePrecision(normalizePrecision(dto.toDatePrecision())) + .toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision())) .notes(blankToNull(dto.notes())) .build(); @@ -178,9 +179,9 @@ public class RelationshipService { rel.setRelatedPerson(newObject); rel.setRelationType(dto.relationType()); rel.setFromDate(dto.fromDate()); - rel.setFromDatePrecision(normalizePrecision(dto.fromDatePrecision())); + rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision())); rel.setToDate(dto.toDate()); - rel.setToDatePrecision(normalizePrecision(dto.toDatePrecision())); + rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision())); rel.setNotes(blankToNull(dto.notes())); PersonRelationship saved; @@ -240,31 +241,18 @@ public class RelationshipService { // Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the // user gets a structured 400 instead of the DB CHECK constraint's 500, then order. + // Coherence is shared with the person domain (DatePrecisionValidation); only the order + // check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific. private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision, LocalDate toDate, DatePrecision toPrecision) { - requireDatePrecisionCoherence(fromDate, fromPrecision, "from"); - requireDatePrecisionCoherence(toDate, toPrecision, "to"); + DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from"); + DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to"); if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) { throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES, "toDate " + toDate + " is before fromDate " + fromDate); } } - 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 (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; - } - private static RelationshipDTO toDTO(PersonRelationship r) { Person p = r.getPerson(); Person rp = r.getRelatedPerson();