From 8558567688d51cabcb9d718c62bb41ce64552128 Mon Sep 17 00:00:00 2001 From: marcel Date: Sun, 14 Jun 2026 21:17:36 +0200 Subject: [PATCH] feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #837 Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free. Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`). ## What's in it - **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped. - **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint. - New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6). - `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision. - Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated. - Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019. ## Requirements All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`. ## Test plan - **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds. - **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean. ## Notes for reviewers - **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`. - `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned). - **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Marcel Reviewed-on: https://git.raddatz.cloud/marcel/familienarchiv/pulls/841 --- .specify/rtm.md | 19 ++ CLAUDE.md | 4 +- .../document/DatePrecisionValidation.java | 42 +++ .../familienarchiv/exception/ErrorCode.java | 2 + .../importing/PersonTreeImporter.java | 4 +- .../familienarchiv/person/PersonService.java | 32 +- .../relationship/PersonRelationship.java | 24 +- .../relationship/RelationshipController.java | 13 +- .../relationship/RelationshipService.java | 159 ++++++--- .../dto/CreateRelationshipRequest.java | 15 - .../relationship/dto/RelationshipDTO.java | 8 +- .../dto/RelationshipUpsertRequest.java | 26 ++ .../timeline/TimelineEventService.java | 12 +- .../V78__relationship_years_to_localdate.sql | 43 +++ .../CanonicalImportOrchestratorTest.java | 3 +- .../importing/PersonTreeImporterTest.java | 4 +- .../RelationshipControllerTest.java | 54 +++- .../RelationshipMigrationTest.java | 306 ++++++++++++++++++ .../RelationshipServiceIntegrationTest.java | 64 +++- .../relationship/RelationshipServiceTest.java | 196 ++++++++++- .../timeline/DerivedEventsAssemblyTest.java | 27 +- docs/DEPLOYMENT.md | 23 ++ ...-relationship-dates-localdate-precision.md | 91 ++++++ docs/architecture/db/db-orm.puml | 10 +- docs/architecture/db/db-relationships.puml | 2 + frontend/messages/de.json | 11 + frontend/messages/en.json | 11 + frontend/messages/es.json | 11 + frontend/src/lib/generated/api.ts | 183 ++++++----- .../lib/person/PersonHoverCard.svelte.spec.ts | 20 +- .../src/lib/person/PersonLifeDateField.svelte | 93 +----- .../lib/person/genealogy/StammbaumCard.svelte | 35 +- .../genealogy/StammbaumCard.svelte.test.ts | 28 +- .../genealogy/StammbaumConnectors.svelte | 2 +- .../StammbaumConnectors.svelte.test.ts | 8 +- .../genealogy/StammbaumSidePanel.svelte | 13 +- .../StammbaumSidePanel.svelte.spec.ts | 12 +- .../genealogy/StammbaumTree.svelte.test.ts | 119 +++++-- .../genealogy/layout/buildLayout.test.ts | 15 +- .../genealogy/layout/familyForest.test.ts | 8 +- .../person/genealogy/layout/familyForest.ts | 5 +- .../genealogy/layout/highlightLineage.test.ts | 12 +- frontend/src/lib/person/personLifeDates.ts | 18 +- .../relationship/AddRelationshipForm.svelte | 162 ++++++---- .../AddRelationshipForm.svelte.spec.ts | 126 ++++++-- .../AddRelationshipForm.svelte.test.ts | 51 --- .../relationship/RelationshipChip.svelte | 32 +- .../RelationshipChip.svelte.spec.ts | 45 ++- .../relationship/RelationshipDateField.svelte | 38 +++ .../src/lib/person/relationshipDates.spec.ts | 65 ++++ frontend/src/lib/person/relationshipDates.ts | 30 ++ .../src/lib/person/relationshipLabels.test.ts | 2 + frontend/src/lib/shared/errors.ts | 3 + .../primitives/DateInputWithPrecision.svelte | 106 ++++++ frontend/src/lib/shared/utils/documentDate.ts | 21 ++ .../[id]/PersonRelationshipsCard.svelte | 44 ++- .../PersonRelationshipsCard.svelte.test.ts | 86 ++++- .../routes/persons/[id]/edit/+page.server.ts | 83 +++-- .../persons/[id]/edit/page.server.spec.ts | 95 ++++++ 59 files changed, 2196 insertions(+), 580 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/document/DatePrecisionValidation.java delete mode 100644 backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/CreateRelationshipRequest.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipUpsertRequest.java create mode 100644 backend/src/main/resources/db/migration/V78__relationship_years_to_localdate.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipMigrationTest.java create mode 100644 docs/adr/044-relationship-dates-localdate-precision.md create mode 100644 frontend/src/lib/person/relationship/RelationshipDateField.svelte create mode 100644 frontend/src/lib/person/relationshipDates.spec.ts create mode 100644 frontend/src/lib/person/relationshipDates.ts create mode 100644 frontend/src/lib/shared/primitives/DateInputWithPrecision.svelte diff --git a/.specify/rtm.md b/.specify/rtm.md index 008ff235..d1cab49d 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -139,6 +139,25 @@ | REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done | | REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done | | REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done | +| REQ-001 | store relationship from/to as nullable LocalDate + NOT-NULL DatePrecision (default UNKNOWN) | #837 | relationship-edit-dates | `person/relationship/PersonRelationship.java`, `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#yearColumnsDropped_andNamedCheckConstraintsExist`, `RelationshipServiceTest#addRelationship_persists_with_storage_truth` | Done | +| REQ-002 | V78 backfills non-null years as `{year}-01-01`/YEAR, nulls → null/UNKNOWN, rows preserved | #837 | relationship-edit-dates | `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#backfill_fromYearAndToYear_becomeYearPrecisionDates`, `#backfill_bothNull_leavesDatesNullAndPrecisionsUnknown`, `#backfill_preservesRowCount` | Done | +| REQ-003 | named DB CHECKs: coherence both ends + fromDate ≤ toDate | #837 | relationship-edit-dates | `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#orderCheckConstraint_rejectsToDateBeforeFromDate`, `#coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision` | Done | +| REQ-004 | PUT updates the relationship → 200 RelationshipDTO | #837 | relationship-edit-dates | `person/relationship/RelationshipController#updateRelationship`, `RelationshipService#updateRelationship` | `RelationshipControllerTest#updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user`, `RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes`, `page.server.spec.ts#updateRelationship PUTs to the relId path with the new body` | Done | +| REQ-005 | create + update rejected with 403 without WRITE_ALL | #837 | relationship-edit-dates | `person/relationship/RelationshipController` (`@RequirePermission`) | `RelationshipControllerTest#updateRelationship_returns403_for_READ_ALL_only_user`, `#addRelationship_returns403_for_user_with_READ_ALL_only` | Done | +| REQ-006 | relId not existing / not owned by person → 404 RELATIONSHIP_NOT_FOUND | #837 | relationship-edit-dates | `person/relationship/RelationshipService#loadOwnedRelationship` | `RelationshipServiceTest#updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person`, `RelationshipServiceIntegrationTest#updateRelationship_throws_404_when_rel_belongs_to_different_person` | Done | +| REQ-007 | update with relatedPersonId == {id} → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_VALIDATION_ERROR_on_self_relation` | Done | +| REQ-008 | resulting (person, relatedPerson, type) duplicate → 409 DUPLICATE_RELATIONSHIP | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_DUPLICATE_when_db_constraint_violated` | Done | +| REQ-009 | update to PARENT_OF with reverse PARENT_OF present → 409 CIRCULAR_RELATIONSHIP | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists` | Done | +| REQ-010 | toDate before fromDate → 400 INVALID_RELATIONSHIP_DATES | #837 | relationship-edit-dates | `person/relationship/RelationshipService#validateRelationshipDates`, `exception/ErrorCode`, `frontend/src/lib/shared/errors.ts` | `RelationshipServiceTest#addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate`, `#updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate` | Done | +| REQ-011 | date+UNKNOWN precision, or precision without date → 400 INVALID_DATE_PRECISION | #837 | relationship-edit-dates | `person/relationship/RelationshipService#requireDatePrecisionCoherence` | `RelationshipServiceTest#addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown`, `#addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date` | Done | +| REQ-012 | invalid enum / missing relatedPersonId·relationType / notes > 2000 → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | `person/relationship/RelationshipUpsertRequest` (Bean Validation), `RelationshipController` | `RelationshipControllerTest#updateRelationship_returns400_when_relationType_is_unknown_value`, `#addRelationship_returns400_when_relationType_is_unknown_value` | Done | +| REQ-013 | updating into a family type flags both endpoints (additive) | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_marks_both_endpoints_family_when_updated_to_family_type` | Done | +| REQ-014 | persist + display notes on create, update, read and edit views | #837 | relationship-edit-dates | `person/relationship/RelationshipService`, `frontend/.../AddRelationshipForm.svelte`, `routes/persons/[id]/PersonRelationshipsCard.svelte` | `RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes`, `AddRelationshipForm.svelte.spec.ts#round-trips the notes into the textarea`, `PersonRelationshipsCard.svelte.test.ts#shows the notes line` | Done | +| REQ-015 | detail view shows the date range at its precision; no dates → no date line | #837 | relationship-edit-dates | `frontend/src/lib/person/relationshipDates.ts`, `routes/persons/[id]/PersonRelationshipsCard.svelte` | `relationshipDates.spec.ts`, `PersonRelationshipsCard.svelte.test.ts#renders the date range at its stored precision`, `#renders no date line when the relationship has no dates` | Done | +| REQ-016 | edit affordance opens a form pre-filled with type/person/dates+precision/notes; precision DAY/MONTH/YEAR | #837 | relationship-edit-dates | `frontend/.../AddRelationshipForm.svelte`, `RelationshipDateField.svelte`, `RelationshipChip.svelte` | `AddRelationshipForm.svelte.spec.ts#pre-fills the from-date as dd.mm.yyyy`, `#offers only DAY/MONTH/YEAR in each precision select`, `RelationshipChip.svelte.spec.ts#shows an Edit affordance with an accessible name when canWrite and onEdit` | Done | +| REQ-017 | derived Heirat sources SPOUSE_OF.fromDate + fromDatePrecision | #837 | relationship-edit-dates | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_day_precision_heirat_from_spouse_fromDate` | Done | +| REQ-018 | unauthenticated PUT → 401, no row modified | #837 | relationship-edit-dates | `person/relationship/RelationshipController` (SecurityConfig) | `RelationshipControllerTest#updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service` | Done | +| REQ-019 | while a create/update request is in flight, submit is disabled + shows a progress indicator | #837 | relationship-edit-dates | `frontend/src/lib/person/relationship/AddRelationshipForm.svelte` | `AddRelationshipForm.svelte.spec.ts#disables the submit and shows a progress spinner while a submit is in flight` | Done | | REQ-001 | TimelineEntryDTO carries rootTagId/rootTagName/rootTagColor for LETTER entries, assembled in-transaction (id+name+token only) | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `timeline/TimelineService#mapDocument` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag` | Done | | REQ-002 | the three root-tag fields are nullable and not `@Schema(requiredMode = REQUIRED)` | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `frontend/src/lib/generated/api.ts` (optional) | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields` (+ regenerated `api.ts` shows `rootTag*?`) | Done | | REQ-003 | primary tag = root ancestor of the alphabetically-first assigned tag, resolved via TagService | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `TagServiceTest#resolveRootTags_walksChildToRoot_withRootColor`, `TagServiceIntegrationTest#resolveRootTags_walksPersistedChainToRoot_withRootColor` | Done | diff --git a/CLAUDE.md b/CLAUDE.md index e8f766aa..e079e05b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,7 +170,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop). +**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop). ### Security / Permissions @@ -280,7 +280,7 @@ Back button pattern — use the shared `` component from `$lib/share → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop). +**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop). --- 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/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index bea7cf66..38ff8f98 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -122,6 +122,8 @@ public enum ErrorCode { CIRCULAR_RELATIONSHIP, /** A relationship with the same (person, relatedPerson, type) already exists. 409 */ DUPLICATE_RELATIONSHIP, + /** A relationship's toDate is before its fromDate. 400 */ + INVALID_RELATIONSHIP_DATES, // --- Geschichten (Stories) --- /** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java index df402050..45faade9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/PersonTreeImporter.java @@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.person.PersonType; import org.raddatz.familienarchiv.person.PersonUpsertCommand; import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationshipService; -import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest; import org.springframework.stereotype.Component; import java.io.File; @@ -126,7 +126,7 @@ public class PersonTreeImporter { private boolean addRelationshipIdempotently(UUID person, UUID related, String type) { try { relationshipService.addRelationship(person, - new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null)); + new RelationshipUpsertRequest(related, RelationType.valueOf(type), null, null, null, null, null)); return true; } catch (DomainException e) { if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP 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/PersonRelationship.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/PersonRelationship.java index 889956ce..16497378 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/PersonRelationship.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/PersonRelationship.java @@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.person.Person; import java.time.Instant; +import java.time.LocalDate; import java.util.UUID; @Entity @@ -39,11 +41,25 @@ public class PersonRelationship { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private RelationType relationType; - @Column(name = "from_year") - private Integer fromYear; + // Start/end of the relationship (wedding, employment start, …). The date column + // is nullable, the precision column is NOT NULL with UNKNOWN meaning "no date" — + // the V78 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN) and + // from_date <= to_date. Mirrors Person.{birth,death}Date (ADR-039 / ADR-044). + private LocalDate fromDate; - @Column(name = "to_year") - private Integer toYear; + @Enumerated(EnumType.STRING) + @Column(name = "from_date_precision", nullable = false, length = 16) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private DatePrecision fromDatePrecision = DatePrecision.UNKNOWN; + + private LocalDate toDate; + + @Enumerated(EnumType.STRING) + @Column(name = "to_date_precision", nullable = false, length = 16) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private DatePrecision toDatePrecision = DatePrecision.UNKNOWN; @Column(length = 2000) private String notes; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipController.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipController.java index 9638fc9a..7d83966c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/RelationshipController.java @@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.person.Person; -import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest; import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; @@ -63,11 +63,20 @@ public class RelationshipController { @RequirePermission(Permission.WRITE_ALL) public ResponseEntity addRelationship( @PathVariable UUID id, - @Valid @RequestBody CreateRelationshipRequest dto) { + @Valid @RequestBody RelationshipUpsertRequest dto) { return ResponseEntity.status(HttpStatus.CREATED) .body(relationshipService.addRelationship(id, dto)); } + @PutMapping("/api/persons/{id}/relationships/{relId}") + @RequirePermission(Permission.WRITE_ALL) + public RelationshipDTO updateRelationship( + @PathVariable UUID id, + @PathVariable UUID relId, + @Valid @RequestBody RelationshipUpsertRequest dto) { + return relationshipService.updateRelationship(id, relId, dto); + } + @DeleteMapping("/api/persons/{id}/relationships/{relId}") @ResponseStatus(HttpStatus.NO_CONTENT) @RequirePermission(Permission.WRITE_ALL) 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 ee237617..94cff9b1 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 @@ -1,10 +1,12 @@ 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; -import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; @@ -96,65 +98,129 @@ public class RelationshipService { } @Transactional - public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) { - if (personId.equals(dto.relatedPersonId())) { - throw DomainException.badRequest( - ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves"); - } + public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) { + requireNotSelf(personId, dto.relatedPersonId()); Person person = personService.getById(personId); Person relatedPerson = personService.getById(dto.relatedPersonId()); - validateYears(dto.fromYear(), dto.toYear()); - - if (dto.relationType() == RelationType.PARENT_OF - && relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( - relatedPerson.getId(), personId, RelationType.PARENT_OF)) { - throw DomainException.conflict( - ErrorCode.CIRCULAR_RELATIONSHIP, - "Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId()); - } + validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision()); + requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType()); PersonRelationship rel = PersonRelationship.builder() .person(person) .relatedPerson(relatedPerson) .relationType(dto.relationType()) - .fromYear(dto.fromYear()) - .toYear(dto.toYear()) + .fromDate(dto.fromDate()) + .fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision())) + .toDate(dto.toDate()) + .toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision())) .notes(blankToNull(dto.notes())) .build(); - PersonRelationship saved; - try { - // saveAndFlush so the unique_rel constraint violates synchronously and is - // caught here, not at commit time outside the @Transactional boundary. - saved = relationshipRepository.saveAndFlush(rel); - } catch (DataIntegrityViolationException e) { - throw DomainException.conflict( - ErrorCode.DUPLICATE_RELATIONSHIP, - "Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")"); - } - // Family-graph edges imply both endpoints are family members. Idempotent: the - // setter is a no-op when the person is already flagged, so re-imports stay clean. - if (FAMILY_RELATION_TYPES.contains(dto.relationType())) { - personService.setFamilyMember(person.getId(), true); - personService.setFamilyMember(relatedPerson.getId(), true); - } + PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType()); + flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId()); return toDTO(saved); } + @Transactional + public RelationshipDTO updateRelationship(UUID personId, UUID relId, RelationshipUpsertRequest dto) { + PersonRelationship rel = loadOwnedRelationship(personId, relId); + + // The other party from {personId}'s viewpoint cannot be {personId} itself. + requireNotSelf(personId, dto.relatedPersonId()); + validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision()); + + // Preserve the directed orientation: {personId} keeps whichever role (subject or + // object) it already holds on the row, and the edited "related person" takes the + // other role. So a PARENT_OF edge stays parent→child whether the curator edits it + // from the parent's page or the child's. + boolean viewpointIsSubject = personId.equals(rel.getPerson().getId()); + Person viewpoint = viewpointIsSubject ? rel.getPerson() : rel.getRelatedPerson(); + Person other = personService.getById(dto.relatedPersonId()); + Person newSubject = viewpointIsSubject ? viewpoint : other; + Person newObject = viewpointIsSubject ? other : viewpoint; + + requireNoReverseParent(newSubject.getId(), newObject.getId(), dto.relationType()); + + rel.setPerson(newSubject); + rel.setRelatedPerson(newObject); + rel.setRelationType(dto.relationType()); + rel.setFromDate(dto.fromDate()); + rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision())); + rel.setToDate(dto.toDate()); + rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision())); + rel.setNotes(blankToNull(dto.notes())); + + PersonRelationship saved = persistOrConflict(rel, newSubject.getId(), newObject.getId(), dto.relationType()); + flagFamilyMembership(dto.relationType(), newSubject.getId(), newObject.getId()); + return toDTO(saved); + } + + // --- shared create/update invariants --------------------------------------------- + + // A person cannot be related to themselves, from either viewpoint. + private static void requireNotSelf(UUID viewpointId, UUID relatedPersonId) { + if (viewpointId.equals(relatedPersonId)) { + throw DomainException.badRequest( + ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves"); + } + } + + // A PARENT_OF edge must not already have its mirror (child PARENT_OF parent) stored — + // that would be a cycle. No-op for every other relation type. + private void requireNoReverseParent(UUID subjectId, UUID objectId, RelationType type) { + if (type == RelationType.PARENT_OF + && relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( + objectId, subjectId, RelationType.PARENT_OF)) { + throw DomainException.conflict( + ErrorCode.CIRCULAR_RELATIONSHIP, + "Reverse PARENT_OF already exists between " + subjectId + " and " + objectId); + } + } + + // saveAndFlush so the unique_rel constraint violates synchronously and is caught here, + // inside the @Transactional boundary, not at commit time as a raw 500. + private PersonRelationship persistOrConflict(PersonRelationship rel, UUID subjectId, UUID objectId, RelationType type) { + try { + return relationshipRepository.saveAndFlush(rel); + } catch (DataIntegrityViolationException e) { + throw DomainException.conflict( + ErrorCode.DUPLICATE_RELATIONSHIP, + "Relationship already exists for (" + subjectId + ", " + objectId + ", " + type + ")"); + } + } + + // Family-graph edges imply both endpoints are family members. Idempotent (the setter is + // a no-op when already flagged, so re-imports stay clean) and additive — an edit never + // auto-unflags. + private void flagFamilyMembership(RelationType type, UUID subjectId, UUID objectId) { + if (FAMILY_RELATION_TYPES.contains(type)) { + personService.setFamilyMember(subjectId, true); + personService.setFamilyMember(objectId, true); + } + } + @Transactional public void deleteRelationship(UUID personId, UUID relId) { + PersonRelationship rel = loadOwnedRelationship(personId, relId); + relationshipRepository.delete(rel); + } + + // Loads the row and verifies {personId} is one of its endpoints. A mismatch is 404 + // (not 403): an anti-enumeration choice so a curator cannot probe relationship ids + // belonging to people they cannot see. Shared by update + delete for consistency. + private PersonRelationship loadOwnedRelationship(UUID personId, UUID relId) { PersonRelationship rel = relationshipRepository.findById(relId) .orElseThrow(() -> DomainException.notFound( ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId)); - UUID storageSubject = rel.getPerson().getId(); UUID storageObject = rel.getRelatedPerson().getId(); if (!personId.equals(storageSubject) && !personId.equals(storageObject)) { - throw DomainException.forbidden( + throw DomainException.notFound( + ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship " + relId + " does not belong to person " + personId); } - relationshipRepository.delete(rel); + return rel; } @Transactional @@ -173,10 +239,17 @@ public class RelationshipService { return date != null ? date.getYear() : null; } - private static void validateYears(Integer fromYear, Integer toYear) { - if (fromYear != null && toYear != null && toYear < fromYear) { - throw DomainException.badRequest( - ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear"); + // 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) { + 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); } } @@ -194,8 +267,10 @@ public class RelationshipService { yearOf(rp.getBirthDate()), yearOf(rp.getDeathDate()), r.getRelationType(), - r.getFromYear(), - r.getToYear(), + r.getFromDate(), + r.getFromDatePrecision(), + r.getToDate(), + r.getToDatePrecision(), r.getNotes()); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/CreateRelationshipRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/CreateRelationshipRequest.java deleted file mode 100644 index 2495c361..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/CreateRelationshipRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.raddatz.familienarchiv.person.relationship.dto; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import org.raddatz.familienarchiv.person.relationship.RelationType; - -import java.util.UUID; - -public record CreateRelationshipRequest( - @NotNull UUID relatedPersonId, - @NotNull RelationType relationType, - Integer fromYear, - Integer toYear, - @Size(max = 2000) String notes -) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipDTO.java index 01d4b3d6..cb1cdbc0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipDTO.java @@ -1,8 +1,10 @@ package org.raddatz.familienarchiv.person.relationship.dto; import io.swagger.v3.oas.annotations.media.Schema; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.person.relationship.RelationType; +import java.time.LocalDate; import java.util.UUID; /** @@ -26,7 +28,9 @@ public record RelationshipDTO( Integer relatedPersonBirthYear, Integer relatedPersonDeathYear, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType, - Integer fromYear, - Integer toYear, + LocalDate fromDate, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision, + LocalDate toDate, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision, String notes ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipUpsertRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipUpsertRequest.java new file mode 100644 index 00000000..db96fb3a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/relationship/dto/RelationshipUpsertRequest.java @@ -0,0 +1,26 @@ +package org.raddatz.familienarchiv.person.relationship.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.person.relationship.RelationType; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * Request body for both creating and updating a relationship — the fields are + * identical, so one record serves {@code POST} and {@code PUT} (DRY). A null + * {@code *DatePrecision} is normalized to {@code UNKNOWN} by the service; the + * service then enforces coherence (date ⇔ non-UNKNOWN precision) and order + * (fromDate ≤ toDate). + */ +public record RelationshipUpsertRequest( + @NotNull UUID relatedPersonId, + @NotNull RelationType relationType, + LocalDate fromDate, + DatePrecision fromDatePrecision, + LocalDate toDate, + DatePrecision toDatePrecision, + @Size(max = 2000) String notes +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java index 28147de9..f2ee6d7e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java @@ -290,13 +290,11 @@ public class TimelineEventService { List result = new ArrayList<>(); for (PersonRelationship r : spouseEdges) { if (seen.add(r.getId())) { - // JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded - LocalDate eventDate = r.getFromYear() != null - ? LocalDate.of(r.getFromYear(), 1, 1) - : null; - DatePrecision precision = r.getFromYear() != null - ? DatePrecision.YEAR - : DatePrecision.UNKNOWN; + // JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded. + // The marriage date is the relationship's from_date at its stored precision + // (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year. + LocalDate eventDate = r.getFromDate(); + DatePrecision precision = r.getFromDatePrecision(); String title = r.getPerson().getDisplayName() + " & " + r.getRelatedPerson().getDisplayName(); result.add(new TimelineEntryDTO( diff --git a/backend/src/main/resources/db/migration/V78__relationship_years_to_localdate.sql b/backend/src/main/resources/db/migration/V78__relationship_years_to_localdate.sql new file mode 100644 index 00000000..ab80ae59 --- /dev/null +++ b/backend/src/main/resources/db/migration/V78__relationship_years_to_localdate.sql @@ -0,0 +1,43 @@ +-- V78: person_relationships.from_year/to_year (integer) → from_date/to_date (date) +-- plus NOT NULL precision columns, mirroring persons.{birth,death}_date (V76 / ADR-039). +-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-044). +-- One-way migration: rollback is a targeted pg_restore -t person_relationships from +-- the pre-deploy backup (see docs/DEPLOYMENT.md). The column drop is NOT +-- rolling-deploy-safe — stop the old JAR before running this migration. + +-- 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 person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year) + THEN RAISE EXCEPTION 'V78 aborted: % relationships have from_year > to_year — fix data before migrating', + (SELECT COUNT(*) FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year); + END IF; + IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year = 0 OR to_year = 0) + THEN RAISE EXCEPTION 'V78 aborted: person_relationships table contains from_year=0 or to_year=0 rows — clean data before migrating'; + END IF; +END $$; + +ALTER TABLE person_relationships ADD COLUMN from_date date; +ALTER TABLE person_relationships ADD COLUMN from_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN'; +ALTER TABLE person_relationships ADD COLUMN to_date date; +ALTER TABLE person_relationships ADD COLUMN to_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN'; + +UPDATE person_relationships SET from_date = make_date(from_year, 1, 1), from_date_precision = 'YEAR' + WHERE from_year IS NOT NULL; +UPDATE person_relationships SET to_date = make_date(to_year, 1, 1), to_date_precision = 'YEAR' + WHERE to_year IS NOT NULL; + +-- Named constraints: readable Postgres error messages when violated. +ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_coherence + CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN')); +ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_coherence + CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN')); +ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_date_order + CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date); +ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_precision_values + CHECK (from_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN')); +ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_precision_values + CHECK (to_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN')); + +ALTER TABLE person_relationships DROP COLUMN from_year; +ALTER TABLE person_relationships DROP COLUMN to_year; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportOrchestratorTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportOrchestratorTest.java index 56a868bb..c1aca067 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportOrchestratorTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportOrchestratorTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationshipService; @@ -169,7 +170,7 @@ class CanonicalImportOrchestratorTest { RelationshipDTO edge = new RelationshipDTO( UUID.randomUUID(), parentId, childId, "Parent", null, null, "Child", null, null, - RelationType.PARENT_OF, null, null, null); + RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null); when(relationshipService.getFamilyNetwork()) .thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge))); when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of())); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java index d43191bb..a1ff6ea6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/PersonTreeImporterTest.java @@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.person.PersonUpsertCommand; import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationshipService; -import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest; import java.nio.file.Files; import java.nio.file.Path; @@ -76,7 +76,7 @@ class PersonTreeImporterTest { new PersonTreeImporter(personService, relationshipService) .load(json.toFile()); - ArgumentCaptor captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class); verify(relationshipService).addRelationship(eq(idA), captor.capture()); assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB); assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java index 91f35eea..bec1b1c7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.person.relationship; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; @@ -25,6 +26,8 @@ import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -98,7 +101,7 @@ class RelationshipControllerTest { UUID.randomUUID(), PERSON_ID, OTHER_ID, "Alice Müller", 1900, 1980, "Bob Müller", 1930, null, - RelationType.PARENT_OF, null, null, null); + RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null); when(relationshipService.getFamilyNetwork()) .thenReturn(new NetworkDTO(List.of(node), List.of(edge))); @@ -139,7 +142,7 @@ class RelationshipControllerTest { UUID.randomUUID(), PERSON_ID, OTHER_ID, "Alice Müller", null, null, "Bob Müller", null, null, - RelationType.PARENT_OF, null, null, null); + RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null); when(relationshipService.addRelationship(any(), any())).thenReturn(created); mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) @@ -158,4 +161,51 @@ class RelationshipControllerTest { mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())) .andExpect(status().isNoContent()); } + + // ─── PUT /api/persons/{id}/relationships/{relId} ────────────────────────── + + @Test + @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) + void updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception { + UUID relId = UUID.randomUUID(); + RelationshipDTO updated = new RelationshipDTO( + relId, PERSON_ID, OTHER_ID, + "Alice Müller", null, null, + "Bob Müller", null, null, + RelationType.SPOUSE_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null); + when(relationshipService.updateRelationship(any(), any(), any())).thenReturn(updated); + + mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.relationType").value("SPOUSE_OF")); + } + + @Test + void updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service() throws Exception { + mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}")) + .andExpect(status().isUnauthorized()); + verify(relationshipService, never()).updateRelationship(any(), any(), any()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void updateRelationship_returns403_for_READ_ALL_only_user() throws Exception { + mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) + void updateRelationship_returns400_when_relationType_is_unknown_value() throws Exception { + mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}")) + .andExpect(status().isBadRequest()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipMigrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipMigrationTest.java new file mode 100644 index 00000000..3a1a1f3a --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipMigrationTest.java @@ -0,0 +1,306 @@ +package org.raddatz.familienarchiv.person.relationship; + +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.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies V78: person_relationships.from_year/to_year (integer) become + * from_date/to_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. Mirrors + * {@code PersonBirthDeathMigrationTest} (V76 / ADR-039). + * + *

Runs Flyway programmatically (no Spring context): each test gets its own + * database so the staged migrate-to-V77 → seed → migrate-to-latest flow and + * the abort cases cannot interfere with each other. Uses a real Postgres + * container — H2 does not honour CHECK constraints. + */ +class RelationshipMigrationTest { + + 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_v78_" + 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_abortsWhenFromYearAfterToYear() throws SQLException { + migrateTo("77"); + UUID a = seedPerson("Alpha"); + UUID b = seedPerson("Beta"); + seedRelationship(a, b, "SPOUSE_OF", 1958, 1923); + + assertThatThrownBy(this::migrateToLatest) + .hasMessageContaining("V78 aborted") + .hasMessageContaining("from_year > to_year"); + } + + @Test + void precheck_abortsWhenYearZeroPresent() throws SQLException { + migrateTo("77"); + UUID a = seedPerson("Alpha"); + UUID b = seedPerson("Beta"); + seedRelationship(a, b, "FRIEND", 0, null); + + assertThatThrownBy(this::migrateToLatest) + .hasMessageContaining("V78 aborted") + .hasMessageContaining("from_year=0 or to_year=0"); + } + + @Test + void backfill_fromYearAndToYear_becomeYearPrecisionDates() throws SQLException { + migrateTo("77"); + UUID a = seedPerson("Alpha"); + UUID b = seedPerson("Beta"); + seedRelationship(a, b, "SPOUSE_OF", 1923, 1958); + + migrateToLatest(); + + RelationDates row = relationDates(a, b, "SPOUSE_OF"); + assertThat(row.fromDate()).hasToString("1923-01-01"); + assertThat(row.fromPrecision()).isEqualTo("YEAR"); + assertThat(row.toDate()).hasToString("1958-01-01"); + assertThat(row.toPrecision()).isEqualTo("YEAR"); + } + + @Test + void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException { + migrateTo("77"); + UUID a = seedPerson("Alpha"); + UUID b = seedPerson("Beta"); + seedRelationship(a, b, "FRIEND", null, null); + + migrateToLatest(); + + RelationDates row = relationDates(a, b, "FRIEND"); + assertThat(row.fromDate()).isNull(); + assertThat(row.fromPrecision()).isEqualTo("UNKNOWN"); + assertThat(row.toDate()).isNull(); + assertThat(row.toPrecision()).isEqualTo("UNKNOWN"); + } + + @Test + void backfill_preservesRowCount() throws SQLException { + migrateTo("77"); + UUID a = seedPerson("Alpha"); + UUID b = seedPerson("Beta"); + UUID c = seedPerson("Gamma"); + seedRelationship(a, b, "SPOUSE_OF", 1923, 1958); + seedRelationship(a, c, "FRIEND", null, null); + + migrateToLatest(); + + assertThat(countWhere("1 = 1")).isEqualTo(2); + } + + @Test + void orderCheckConstraint_rejectsToDateBeforeFromDate() throws SQLException { + migrateTo("77"); + UUID a = seedPerson("Alpha"); + UUID b = seedPerson("Beta"); + migrateToLatest(); + + assertThatThrownBy(() -> insertDatedRelationship( + a, b, "FRIEND", "1958-01-01", "YEAR", "1923-01-01", "YEAR")) + .hasMessageContaining("chk_relationship_date_order"); + } + + @Test + void coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision() throws SQLException { + migrateTo("77"); + UUID a = seedPerson("Alpha"); + UUID b = seedPerson("Beta"); + migrateToLatest(); + + assertThatThrownBy(() -> insertDatedRelationship( + a, b, "FRIEND", "1923-01-01", "UNKNOWN", null, "UNKNOWN")) + .hasMessageContaining("chk_relationship_from_coherence"); + } + + @Test + void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException { + migrateTo("77"); + UUID a = seedPerson("Alpha"); + UUID b = seedPerson("Beta"); + seedRelationship(a, b, "SPOUSE_OF", 1923, 1958); + + migrateToLatest(); + + assertThat(columnExists("from_year")).isFalse(); + assertThat(columnExists("to_year")).isFalse(); + assertThat(columnExists("from_date")).isTrue(); + assertThat(columnExists("to_date")).isTrue(); + for (String constraint : new String[]{ + "chk_relationship_from_coherence", + "chk_relationship_to_coherence", + "chk_relationship_date_order", + "chk_relationship_from_precision_values", + "chk_relationship_to_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 UUID seedPerson(String lastName) throws SQLException { + UUID id = UUID.randomUUID(); + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO persons (id, last_name, person_type, family_member, provisional) " + + "VALUES (?, ?, 'PERSON', false, false)")) { + stmt.setObject(1, id); + stmt.setString(2, lastName); + stmt.executeUpdate(); + } + return id; + } + + private void seedRelationship(UUID personId, UUID relatedId, String type, Integer fromYear, Integer toYear) + throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO person_relationships (id, person_id, related_person_id, relation_type, from_year, to_year) " + + "VALUES (gen_random_uuid(), ?, ?, ?, ?, ?)")) { + stmt.setObject(1, personId); + stmt.setObject(2, relatedId); + stmt.setString(3, type); + stmt.setObject(4, fromYear); + stmt.setObject(5, toYear); + stmt.executeUpdate(); + } + } + + private void insertDatedRelationship(UUID personId, UUID relatedId, String type, + String fromDate, String fromPrecision, + String toDate, String toPrecision) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO person_relationships " + + "(id, person_id, related_person_id, relation_type, from_date, from_date_precision, to_date, to_date_precision) " + + "VALUES (gen_random_uuid(), ?, ?, ?, CAST(? AS date), ?, CAST(? AS date), ?)")) { + stmt.setObject(1, personId); + stmt.setObject(2, relatedId); + stmt.setString(3, type); + stmt.setObject(4, fromDate); + stmt.setString(5, fromPrecision); + stmt.setObject(6, toDate); + stmt.setString(7, toPrecision); + stmt.executeUpdate(); + } + } + + private record RelationDates(Object fromDate, String fromPrecision, Object toDate, String toPrecision) {} + + private RelationDates relationDates(UUID personId, UUID relatedId, String type) throws SQLException { + try (Connection conn = connect(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT from_date, from_date_precision, to_date, to_date_precision " + + "FROM person_relationships WHERE person_id = ? AND related_person_id = ? AND relation_type = ?")) { + stmt.setObject(1, personId); + stmt.setObject(2, relatedId); + stmt.setString(3, type); + try (ResultSet rs = stmt.executeQuery()) { + assertThat(rs.next()).as("relationship exists").isTrue(); + return new RelationDates( + rs.getObject("from_date"), + rs.getString("from_date_precision"), + rs.getObject("to_date"), + rs.getString("to_date_precision")); + } + } + } + + private long countWhere(String condition) throws SQLException { + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM person_relationships 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 = 'person_relationships' 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()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceIntegrationTest.java index acbb3825..859180b5 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceIntegrationTest.java @@ -4,10 +4,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.config.FlywayConfig; +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.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO; import org.raddatz.familienarchiv.person.PersonNameAliasRepository; @@ -20,6 +21,7 @@ import org.springframework.context.annotation.Import; import jakarta.persistence.EntityManager; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @@ -65,13 +67,17 @@ class RelationshipServiceIntegrationTest { @Test void addRelationship_stores_and_is_readable() { - var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, + LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, null); RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto); assertThat(created.id()).isNotNull(); assertThat(created.personId()).isEqualTo(alice.getId()); assertThat(created.relatedPersonId()).isEqualTo(bob.getId()); + assertThat(created.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1)); + assertThat(created.fromDatePrecision()).isEqualTo(DatePrecision.YEAR); + assertThat(created.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); List rels = relationshipService.getRelationships(alice.getId()); assertThat(rels).hasSize(1); @@ -80,7 +86,7 @@ class RelationshipServiceIntegrationTest { @Test void addRelationship_throws_409_when_duplicate() { - var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null); relationshipService.addRelationship(alice.getId(), dto); assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto)) @@ -93,9 +99,9 @@ class RelationshipServiceIntegrationTest { void addRelationship_throws_409_when_circular_parent() { // alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected. relationshipService.addRelationship(alice.getId(), - new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null)); + new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null)); - var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null); + var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null); assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse)) .isInstanceOf(DomainException.class) .extracting("code") @@ -103,28 +109,58 @@ class RelationshipServiceIntegrationTest { } @Test - void deleteRelationship_throws_403_when_rel_belongs_to_different_person() { + void deleteRelationship_throws_404_when_rel_belongs_to_different_person() { RelationshipDTO created = relationshipService.addRelationship(alice.getId(), - new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null)); + new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null)); - // Charlie is unrelated to this row. + // Charlie is unrelated to this row. Ownership mismatch is 404, not 403, so a + // curator cannot enumerate relationship ids belonging to people they can't see + // (anti-enumeration; aligned with the PUT endpoint — ADR-044). assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id())) .isInstanceOf(DomainException.class) .extracting("code") - .isEqualTo(ErrorCode.FORBIDDEN); + .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); // The row is still there. assertThat(relationshipRepository.findById(created.id())).isPresent(); } + @Test + void updateRelationship_persists_new_type_dates_and_notes() { + RelationshipDTO created = relationshipService.addRelationship(alice.getId(), + new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null)); + + RelationshipDTO updated = relationshipService.updateRelationship(alice.getId(), created.id(), + new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, + LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day")); + + assertThat(updated.id()).isEqualTo(created.id()); + assertThat(updated.relationType()).isEqualTo(RelationType.SPOUSE_OF); + assertThat(updated.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12)); + assertThat(updated.fromDatePrecision()).isEqualTo(DatePrecision.DAY); + assertThat(updated.notes()).isEqualTo("wedding day"); + } + + @Test + void updateRelationship_throws_404_when_rel_belongs_to_different_person() { + RelationshipDTO created = relationshipService.addRelationship(alice.getId(), + new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null)); + + assertThatThrownBy(() -> relationshipService.updateRelationship(charlie.getId(), created.id(), + new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null))) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); + } + @Test void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() { // V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF) // and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF. relationshipService.addRelationship(alice.getId(), - new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null)); + new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null)); - var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null); + var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null, null, null); assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse)) .isInstanceOf(DomainException.class) .extracting("code") @@ -135,7 +171,7 @@ class RelationshipServiceIntegrationTest { void deleteRelationship_succeeds_for_symmetric_type_from_either_side() { // alice SPOUSE_OF bob. Bob deletes from his side. RelationshipDTO created = relationshipService.addRelationship(alice.getId(), - new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null)); + new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null)); relationshipService.deleteRelationship(bob.getId(), created.id()); @@ -148,7 +184,7 @@ class RelationshipServiceIntegrationTest { // edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit // setFamilyMember(true) call below is the thing under test, not the auto-flip. relationshipService.addRelationship(alice.getId(), - new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null)); + new RelationshipUpsertRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null, null, null)); relationshipService.setFamilyMember(charlie.getId(), false); NetworkDTO before = relationshipService.getFamilyNetwork(); @@ -165,7 +201,7 @@ class RelationshipServiceIntegrationTest { @Test void delete_person_cascades_to_relationships() { RelationshipDTO created = relationshipService.addRelationship(alice.getId(), - new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null)); + new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null)); UUID relId = created.id(); assertThat(relationshipRepository.findById(relId)).isPresent(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceTest.java index d8d41a9d..30449e6d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipServiceTest.java @@ -6,16 +6,18 @@ 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 org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; -import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; +import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest; import org.raddatz.familienarchiv.person.PersonService; import org.springframework.dao.DataIntegrityViolationException; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; import java.time.Instant; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -59,9 +61,9 @@ class RelationshipServiceTest { charlie = person("Charlie"); } - // --- Nora blocker 1 --- + // --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) --- @Test - void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() { + void deleteRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() { UUID relId = UUID.randomUUID(); PersonRelationship rel = parentOf(alice, bob, relId); when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); @@ -69,7 +71,7 @@ class RelationshipServiceTest { assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId)) .isInstanceOf(DomainException.class) .extracting("code") - .isEqualTo(ErrorCode.FORBIDDEN); + .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); verify(relationshipRepository, never()).delete(any()); } @@ -82,7 +84,7 @@ class RelationshipServiceTest { when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true); - var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null); + var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null); assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto)) .isInstanceOf(DomainException.class) .extracting("code") @@ -98,7 +100,7 @@ class RelationshipServiceTest { bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false); when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel")); - var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null); assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) .isInstanceOf(DomainException.class) .extracting("code") @@ -107,7 +109,7 @@ class RelationshipServiceTest { @Test void addRelationship_throws_BAD_REQUEST_when_self_relationship() { - var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null); + var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null); assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) .isInstanceOf(DomainException.class) .extracting("code") @@ -116,14 +118,42 @@ class RelationshipServiceTest { } @Test - void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() { + void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() { when(personService.getById(alice.getId())).thenReturn(alice); when(personService.getById(bob.getId())).thenReturn(bob); - var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, + LocalDate.of(1950, 1, 1), DatePrecision.YEAR, + LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null); assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) .isInstanceOf(DomainException.class) .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); + .isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES); + verify(relationshipRepository, never()).saveAndFlush(any()); + } + + @Test + void addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown() { + when(personService.getById(alice.getId())).thenReturn(alice); + when(personService.getById(bob.getId())).thenReturn(bob); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, + LocalDate.of(1950, 1, 1), DatePrecision.UNKNOWN, null, null, null); + assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.INVALID_DATE_PRECISION); + verify(relationshipRepository, never()).saveAndFlush(any()); + } + + @Test + void addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date() { + when(personService.getById(alice.getId())).thenReturn(alice); + when(personService.getById(bob.getId())).thenReturn(bob); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, + null, DatePrecision.DAY, null, null, null); + assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.INVALID_DATE_PRECISION); verify(relationshipRepository, never()).saveAndFlush(any()); } @@ -140,13 +170,16 @@ class RelationshipServiceTest { return r; }); - var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born"); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, + LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born"); var result = service.addRelationship(alice.getId(), dto); assertThat(result.personId()).isEqualTo(alice.getId()); assertThat(result.relatedPersonId()).isEqualTo(bob.getId()); assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF); - assertThat(result.fromYear()).isEqualTo(1900); + assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1)); + assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR); + assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); assertThat(result.notes()).isEqualTo("first born"); } @@ -166,7 +199,7 @@ class RelationshipServiceTest { return r; }); - var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null); service.addRelationship(alice.getId(), dto); verify(personService).setFamilyMember(alice.getId(), true); @@ -187,7 +220,7 @@ class RelationshipServiceTest { return r; }); - var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null); + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null); service.addRelationship(alice.getId(), dto); verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean()); @@ -216,6 +249,131 @@ class RelationshipServiceTest { .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); } + // --- updateRelationship (REQ-004/006/007/008/009/010/013) --- + + @Test + void updateRelationship_throws_NOT_FOUND_when_relId_unknown() { + UUID relId = UUID.randomUUID(); + when(relationshipRepository.findById(relId)).thenReturn(Optional.empty()); + + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null); + assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); + verify(relationshipRepository, never()).saveAndFlush(any()); + } + + @Test + void updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = parentOf(alice, bob, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null); + assertThatThrownBy(() -> service.updateRelationship(charlie.getId(), relId, dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); + verify(relationshipRepository, never()).saveAndFlush(any()); + } + + @Test + void updateRelationship_throws_VALIDATION_ERROR_on_self_relation() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + + var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null); + assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + verify(relationshipRepository, never()).saveAndFlush(any()); + } + + @Test + void updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, + LocalDate.of(1950, 1, 1), DatePrecision.YEAR, + LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null); + assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES); + verify(relationshipRepository, never()).saveAndFlush(any()); + } + + @Test + void updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + when(personService.getById(bob.getId())).thenReturn(bob); + when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( + bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(true); + + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null); + assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP); + verify(relationshipRepository, never()).saveAndFlush(any()); + } + + @Test + void updateRelationship_throws_DUPLICATE_when_db_constraint_violated() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + when(personService.getById(bob.getId())).thenReturn(bob); + when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel")); + + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null); + assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP); + } + + @Test + void updateRelationship_updates_fields_and_returns_dto() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + when(personService.getById(bob.getId())).thenReturn(bob); + when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, + LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day"); + var result = service.updateRelationship(alice.getId(), relId, dto); + + assertThat(result.relationType()).isEqualTo(RelationType.SPOUSE_OF); + assertThat(result.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12)); + assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.DAY); + assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN); + assertThat(result.notes()).isEqualTo("wedding day"); + } + + @Test + void updateRelationship_marks_both_endpoints_family_when_updated_to_family_type() { + UUID relId = UUID.randomUUID(); + PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId); + when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); + when(personService.getById(bob.getId())).thenReturn(bob); + when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SIBLING_OF, null, null, null, null, null); + service.updateRelationship(alice.getId(), relId, dto); + + verify(personService).setFamilyMember(alice.getId(), true); + verify(personService).setFamilyMember(bob.getId(), true); + } + @Test void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() { // alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result). @@ -260,11 +418,15 @@ class RelationshipServiceTest { } private static PersonRelationship parentOf(Person parent, Person child, UUID id) { + return relOf(parent, child, RelationType.PARENT_OF, id); + } + + private static PersonRelationship relOf(Person subject, Person object, RelationType type, UUID id) { return PersonRelationship.builder() .id(id) - .person(parent) - .relatedPerson(child) - .relationType(RelationType.PARENT_OF) + .person(subject) + .relatedPerson(object) + .relationType(type) .createdAt(Instant.now()) .build(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java index ddef7bd6..6e638dbc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/DerivedEventsAssemblyTest.java @@ -81,12 +81,19 @@ class DerivedEventsAssemblyTest { } private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) { + return makeSpouseEdgeWithDate(a, b, + fromYear != null ? LocalDate.of(fromYear, 1, 1) : null, + fromYear != null ? DatePrecision.YEAR : DatePrecision.UNKNOWN); + } + + private PersonRelationship makeSpouseEdgeWithDate(Person a, Person b, LocalDate fromDate, DatePrecision precision) { return PersonRelationship.builder() .id(UUID.randomUUID()) .person(a) .relatedPerson(b) .relationType(RelationType.SPOUSE_OF) - .fromYear(fromYear) + .fromDate(fromDate) + .fromDatePrecision(precision) .build(); } @@ -223,6 +230,24 @@ class DerivedEventsAssemblyTest { assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN); } + // --- REQ-017 (#837): derived Heirat sources SPOUSE_OF.fromDate at its stored precision --- + + @Test + void should_emit_day_precision_heirat_from_spouse_fromDate() { + Person anna = makePerson(null, DatePrecision.UNKNOWN); + Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN); + PersonRelationship edge = makeSpouseEdgeWithDate(anna, hans, LocalDate.of(1923, 5, 12), DatePrecision.DAY); + when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans)); + when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge)); + + TimelineEntryDTO heirat = service.assembleDerivedEvents().stream() + .filter(e -> e.derivedType() == DerivedEventType.MARRIAGE) + .findFirst().orElseThrow(); + + assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1923, 5, 12)); + assertThat(heirat.precision()).isEqualTo(DatePrecision.DAY); + } + // --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) --- @Test diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4493c2f9..aeb4dab2 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -538,6 +538,29 @@ pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump (For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.) +### Deploy note — V78 (person_relationships from/to → date + precision, #837) + +V78 drops `person_relationships.from_year`/`to_year` after backfilling the new +`from_date`/`to_date` + precision columns — a **one-way migration** (Flyway cannot roll +it back). Like V76 it runs its pre-check + DDL in one atomic Flyway transaction and +needs **no maintenance window** (single-writer archive, no concurrent importers). + +It is, however, **not rolling-deploy-safe**: the previously-running JAR still maps the +`from_year`/`to_year` columns, so it would error against the migrated schema. Deploy in +this order (the default stop-then-start, single-instance deploy already satisfies it): + +1. Take a manual `pg_dump` (see above) and confirm it completed. +2. **Stop the old JAR**, then **start the new JAR** — Flyway V78 runs first thing on the + new JAR's startup, before any request is served. Never run the old and new JARs + concurrently across this migration. + +If post-deploy data issues are found, restore **only the person_relationships table** +from the pre-migration dump: + +```bash +pg_restore -t person_relationships -d ${POSTGRES_DB} backup-YYYYMMDD.dump +``` + ### Rollback Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command: diff --git a/docs/adr/044-relationship-dates-localdate-precision.md b/docs/adr/044-relationship-dates-localdate-precision.md new file mode 100644 index 00000000..1e2c7ea8 --- /dev/null +++ b/docs/adr/044-relationship-dates-localdate-precision.md @@ -0,0 +1,91 @@ +# ADR-044 — Relationship dates become LocalDate + DatePrecision; relationships become editable + +**Status:** Accepted +**Date:** 2026-06-14 +**Issue:** #837 (Zeitstrahl milestone; deferred follow-up to #773 / ADR-039) + +## Context + +`PersonRelationship` stored its span as `Integer fromYear`/`toYear`. A wedding could +never be more precise than `1923`, while `Person` (ADR-039), `Document`, and +`TimelineEvent` already carry full `DatePrecision`. Relationships also supported only +create + delete: fixing a wrong type, a wrong person, or adding a date learned later +meant deleting and re-creating the edge — losing `createdAt`. A `notes` column existed +that no form set and nothing displayed. + +V78 replaces the two integer columns with `from_date`/`to_date` (`DATE`, nullable) plus +`from_date_precision`/`to_date_precision` (`VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'`), +backfilling existing years as `YYYY-01-01` at `YEAR` precision — exactly the V76 / ADR-039 +pattern applied to the relationship edge. A new `PUT /api/persons/{id}/relationships/{relId}` +makes relationships editable, and `notes` is activated end to end. + +## Decisions + +### 1. Mirror ADR-039 verbatim for the relationship edge + +`DatePrecision` is imported cross-domain from `document/` (ADR-039 §1 — value-type +sharing, not a layering breach). The precision columns are NOT NULL default `UNKNOWN`, +guarded by five named CHECK constraints (`chk_relationship_from_coherence`, +`chk_relationship_to_coherence`, `chk_relationship_date_order`, +`chk_relationship_{from,to}_precision_values`). `RelationshipService.validateRelationshipDates` +enforces the same rules first, so the user gets a structured 400 +(`INVALID_DATE_PRECISION` for coherence, the new `INVALID_RELATIONSHIP_DATES` for a +`toDate < fromDate` order violation) instead of a constraint-violation 500. The form +offers only **DAY / MONTH / YEAR**; storage still accepts all seven values, and a +stored non-offered precision seeds the edit select as `YEAR` (ADR-039 §2). + +### 2. Update re-runs every create invariant + +An edit can violate the same invariants as a create, so `updateRelationship` re-runs +all of them: self-relation (`VALIDATION_ERROR`), date coherence + order, reverse +`PARENT_OF` (`CIRCULAR_RELATIONSHIP`), and the `(person, relatedPerson, type)` unique +constraint via `saveAndFlush` (`DUPLICATE_RELATIONSHIP`). Editing into a family type +flags both endpoints as family members (additive; never auto-unflags). The directed +orientation is preserved per viewpoint — whichever endpoint `{personId}` already holds +on the row stays put — so a `PARENT_OF` edge remains parent→child whether edited from +either person's page. + +### 3. No optimistic locking (`@Version`) + +`PersonRelationship` gains no `@Version`; the edit is last-write-wins, matching the +person edit form. This is a single-writer family archive, and it avoids the managed- +`setVersion` pitfall (a `setVersion` on a managed entity is silently ignored by +Hibernate — see the integration-test note in #496-era work). If concurrent curation +ever becomes real, add `@Version` plus an explicit client-version compare then. + +### 4. IDOR / anti-enumeration: ownership mismatch is 404, for PUT **and** DELETE + +A `{relId}` that does not belong to `{personId}` returns 404 `RELATIONSHIP_NOT_FOUND` +(a shared `loadOwnedRelationship` helper), so a curator cannot probe relationship ids +belonging to people they cannot see. This **aligns `deleteRelationship`** from its +former 403 to 404 in the same change, so the two mutating endpoints behave identically +on the same mismatch. + +### 5. Derived marriage events gain precision for free + +`TimelineEventService.buildMarriageEvents` now sources the Heirat date from the +`SPOUSE_OF` row's `from_date` + `from_date_precision` (previously +`LocalDate.of(fromYear, 1, 1)` at hard-coded `YEAR`). A DAY-precision wedding now +surfaces the exact day on the Zeitstrahl. `RelationshipInferenceService` is unchanged +— it is time-ignorant and never read the year fields. + +### 6. `relationshipDates.ts` lives in `$lib/person/`, no new boundary + +`formatRelationshipDateRange` mirrors `personLifeDates.ts` and delegates entirely to the +already-tested `formatDocumentDate` (zero new precision logic). It sits in `$lib/person/` +next to `personLifeDates.ts`; its only cross-domain import is `formatDocumentDate` from +`$lib/shared/utils/`, which the existing `person → shared` rule in `eslint.config.js` +already permits — **no new eslint boundary rule is added**. + +## Consequences + +- V78 is one-way (columns dropped) and is **not** rolling-deploy-safe — the running JAR + maps `from_year` until redeploy. Deploy order: **stop old JAR → run Flyway V78 → + start new JAR**. Rollback = targeted `pg_restore -t person_relationships` from the + pre-deploy dump (see `docs/DEPLOYMENT.md` §8). No maintenance window needed + (single-writer archive). +- Relationships are fully editable (type, related person, dates, notes) and the read + view shows the date range + notes. +- `RelationshipDTO` drops `fromYear`/`toYear` for `fromDate`/`fromDatePrecision`/ + `toDate`/`toDatePrecision`; the `personBirthYear`/`relatedPersonBirthYear` derived + fields are unaffected (ADR-039 §3). diff --git a/docs/architecture/db/db-orm.puml b/docs/architecture/db/db-orm.puml index f84d2112..a5e57e7b 100644 --- a/docs/architecture/db/db-orm.puml +++ b/docs/architecture/db/db-orm.puml @@ -1,6 +1,6 @@ @startuml db-orm -' Schema source: Flyway V1–V77 (excl. V37, V43 — intentionally removed) -' Schema as of: V77 (2026-06-12) +' Schema source: Flyway V1–V78 (excl. V37, V43 — intentionally removed) +' Schema as of: V78 (2026-06-14) ' ⚠ This is a versioned snapshot. Update when the schema changes significantly. hide circle @@ -211,8 +211,10 @@ package "Persons" { person_id : UUID <> related_person_id : UUID <> relation_type : VARCHAR(30) NOT NULL - from_year : INTEGER - to_year : INTEGER + from_date : DATE + from_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN' + to_date : DATE + to_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN' notes : VARCHAR(2000) created_at : TIMESTAMPTZ NOT NULL } diff --git a/docs/architecture/db/db-relationships.puml b/docs/architecture/db/db-relationships.puml index eccc8501..04fe68bf 100644 --- a/docs/architecture/db/db-relationships.puml +++ b/docs/architecture/db/db-relationships.puml @@ -7,6 +7,8 @@ ' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date + ' precision columns; columns only, no new FK relationships, diagram unchanged. ' Note: V77 adds the timeline_events table + two join tables (Timeline package below). +' Note: V78 swaps person_relationships.from_year/to_year for from_date/to_date + +' precision columns; columns only, no new FK relationships, diagram unchanged. hide circle skinparam linetype ortho diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 001e4bab..125f0a11 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -651,6 +651,7 @@ "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_relationship_dates": "Das Ende-Datum darf nicht vor dem Beginn-Datum liegen.", "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.", @@ -1221,6 +1222,16 @@ "relation_form_field_from_year": "Von Jahr", "relation_form_field_to_year": "Bis Jahr", "relation_form_year_placeholder": "z.B. 1920", + "relation_label_from_date": "Beginn (Datum)", + "relation_label_to_date": "Ende (Datum)", + "relation_label_date_precision": "Genauigkeit", + "relation_precision_day": "Genaues Datum (Tag)", + "relation_precision_month": "Monat bekannt", + "relation_precision_year": "Nur Jahreszahl", + "relation_label_notes": "Notizen", + "relation_notes_placeholder": "Optionaler Hinweis zu dieser Beziehung", + "relation_date_placeholder_hint": "Leer lassen, wenn unbekannt", + "relation_edit": "Beziehung bearbeiten", "person_relationships_heading": "Beziehungen", "person_relationships_empty": "Noch keine Beziehungen bekannt.", "timeline_aria_label": "Zeitachse Dokumentdichte", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e34a27fb..9ff1000e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -651,6 +651,7 @@ "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_relationship_dates": "The end date must not be before the start date.", "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.", @@ -1221,6 +1222,16 @@ "relation_form_field_from_year": "From year", "relation_form_field_to_year": "To year", "relation_form_year_placeholder": "e.g. 1920", + "relation_label_from_date": "Start date", + "relation_label_to_date": "End date", + "relation_label_date_precision": "Precision", + "relation_precision_day": "Exact date (day)", + "relation_precision_month": "Month known", + "relation_precision_year": "Year only", + "relation_label_notes": "Notes", + "relation_notes_placeholder": "Optional note about this relationship", + "relation_date_placeholder_hint": "Leave empty if unknown", + "relation_edit": "Edit relationship", "person_relationships_heading": "Relationships", "person_relationships_empty": "No relationships known yet.", "timeline_aria_label": "Document density timeline", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index dfb5fc52..2cd4548c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -651,6 +651,7 @@ "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_relationship_dates": "La fecha de fin no puede ser anterior a la de inicio.", "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.", @@ -1221,6 +1222,16 @@ "relation_form_field_from_year": "Desde año", "relation_form_field_to_year": "Hasta año", "relation_form_year_placeholder": "ej. 1920", + "relation_label_from_date": "Fecha de inicio", + "relation_label_to_date": "Fecha de fin", + "relation_label_date_precision": "Precisión", + "relation_precision_day": "Fecha exacta (día)", + "relation_precision_month": "Mes conocido", + "relation_precision_year": "Solo año", + "relation_label_notes": "Notas", + "relation_notes_placeholder": "Nota opcional sobre esta relación", + "relation_date_placeholder_hint": "Dejar vacío si es desconocido", + "relation_edit": "Editar relación", "person_relationships_heading": "Relaciones", "person_relationships_empty": "Aún no se conocen relaciones.", "timeline_aria_label": "Cronología de densidad de documentos", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 7c5dc387..33f9b3ab 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -100,6 +100,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/relationships/{relId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateRelationship"]; + post?: never; + delete: operations["deleteRelationship"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/geschichten/{id}/items/reorder": { parameters: { query?: never; @@ -1640,22 +1656,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/persons/{id}/relationships/{relId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["deleteRelationship"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/persons/{id}/aliases/{aliasId}": { parameters: { query?: never; @@ -1853,6 +1853,50 @@ export interface components { provisional: boolean; readonly displayName: string; }; + RelationshipUpsertRequest: { + /** Format: uuid */ + relatedPersonId: string; + /** @enum {string} */ + relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; + /** Format: date */ + fromDate?: string; + /** @enum {string} */ + fromDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + toDate?: string; + /** @enum {string} */ + toDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + notes?: string; + }; + RelationshipDTO: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + personId: string; + /** Format: uuid */ + relatedPersonId: string; + personDisplayName: string; + /** Format: int32 */ + personBirthYear?: number; + /** Format: int32 */ + personDeathYear?: number; + relatedPersonDisplayName: string; + /** Format: int32 */ + relatedPersonBirthYear?: number; + /** Format: int32 */ + relatedPersonDeathYear?: number; + /** @enum {string} */ + relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; + /** Format: date */ + fromDate?: string; + /** @enum {string} */ + fromDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + toDate?: string; + /** @enum {string} */ + toDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + notes?: string; + }; JourneyReorderDTO: { itemIds?: string[]; }; @@ -2008,42 +2052,6 @@ export interface components { /** Format: uuid */ targetId: string; }; - CreateRelationshipRequest: { - /** Format: uuid */ - relatedPersonId: string; - /** @enum {string} */ - relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; - /** Format: int32 */ - fromYear?: number; - /** Format: int32 */ - toYear?: number; - notes?: string; - }; - RelationshipDTO: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - personId: string; - /** Format: uuid */ - relatedPersonId: string; - personDisplayName: string; - /** Format: int32 */ - personBirthYear?: number; - /** Format: int32 */ - personDeathYear?: number; - relatedPersonDisplayName: string; - /** Format: int32 */ - relatedPersonBirthYear?: number; - /** Format: int32 */ - relatedPersonDeathYear?: number; - /** @enum {string} */ - relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; - /** Format: int32 */ - fromYear?: number; - /** Format: int32 */ - toYear?: number; - notes?: string; - }; PersonNameAliasDTO: { lastName: string; firstName?: string; @@ -3200,6 +3208,54 @@ export interface operations { }; }; }; + updateRelationship: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + relId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RelationshipUpsertRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["RelationshipDTO"]; + }; + }; + }; + }; + deleteRelationship: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + relId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; reorderItems: { parameters: { query?: never; @@ -3663,7 +3719,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateRelationshipRequest"]; + "application/json": components["schemas"]["RelationshipUpsertRequest"]; }; }; responses: { @@ -5909,27 +5965,6 @@ export interface operations { }; }; }; - deleteRelationship: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - relId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; removeAlias: { parameters: { query?: never; diff --git a/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts index 2ee2fb6e..b6a295ba 100644 --- a/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts +++ b/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts @@ -193,7 +193,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-spouse', personDisplayName: 'Auguste', relatedPersonDisplayName: 'Otto Raddatz', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'r2', @@ -201,7 +203,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-friend', personDisplayName: 'Auguste', relatedPersonDisplayName: 'Karl Friend', - relationType: 'FRIEND' + relationType: 'FRIEND', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'r3', @@ -209,7 +213,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-sibling', personDisplayName: 'Auguste', relatedPersonDisplayName: 'Marie Sister', - relationType: 'SIBLING_OF' + relationType: 'SIBLING_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ]; render(PersonHoverCard, { @@ -235,7 +241,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-aug', personDisplayName: 'Heinrich Raddatz', relatedPersonDisplayName: 'Auguste Raddatz', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ]; render(PersonHoverCard, { @@ -258,7 +266,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-friend', personDisplayName: 'Auguste', relatedPersonDisplayName: 'Karl Friend', - relationType: 'FRIEND' + relationType: 'FRIEND', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ]; render(PersonHoverCard, { diff --git a/frontend/src/lib/person/PersonLifeDateField.svelte b/frontend/src/lib/person/PersonLifeDateField.svelte index 0d56f36a..cd1855bc 100644 --- a/frontend/src/lib/person/PersonLifeDateField.svelte +++ b/frontend/src/lib/person/PersonLifeDateField.svelte @@ -1,17 +1,10 @@ -

- - {legend} - -
-
- - {#if errorMessage} -

{errorMessage}

- {/if} -
-
- -
-
-

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

-
+ diff --git a/frontend/src/lib/person/genealogy/StammbaumCard.svelte b/frontend/src/lib/person/genealogy/StammbaumCard.svelte index 02542f39..07e350f8 100644 --- a/frontend/src/lib/person/genealogy/StammbaumCard.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumCard.svelte @@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js'; import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte'; import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte'; import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels'; +import { formatRelationshipDateRange } from '$lib/person/relationshipDates'; import type { components } from '$lib/generated/api'; type RelationshipDTO = components['schemas']['RelationshipDTO']; @@ -29,13 +30,15 @@ let { type RelationType = NonNullable; -const sortedDirect = $derived([...relationships].sort(byTypeThenYear)); +const sortedDirect = $derived([...relationships].sort(byTypeThenDate)); const topDerived = $derived(inferredRelationships.slice(0, 5)); +let editingRelId = $state(null); -function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number { +function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number { const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType); if (order !== 0) return order; - return (a.fromYear ?? 0) - (b.fromYear ?? 0); + // ISO dates sort lexicographically == chronologically; a missing date sorts first. + return (a.fromDate ?? '').localeCompare(b.fromDate ?? ''); } function relationTypeOrder(t: RelationType | undefined): number { @@ -53,13 +56,13 @@ function relationTypeOrder(t: RelationType | undefined): number { return order[t ?? 'OTHER'] ?? 99; } -function yearRange(rel: RelationshipDTO): string { - const from = rel.fromYear; - const to = rel.toYear; - if (from && to) return `${from}–${to}`; - if (from) return m.relation_year_from({ year: from }); - if (to) return m.relation_year_to({ year: to }); - return ''; +function dateRangeOf(rel: RelationshipDTO): string { + return formatRelationshipDateRange( + rel.fromDate, + rel.fromDatePrecision, + rel.toDate, + rel.toDatePrecision + ); } @@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string { (editingRelId = rel.id) : undefined} /> + {#if editingRelId === rel.id} +
  • + (editingRelId = null)} + /> +
  • + {/if} {/each} {/if} diff --git a/frontend/src/lib/person/genealogy/StammbaumCard.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumCard.svelte.test.ts index 2769ae25..54e5e2d8 100644 --- a/frontend/src/lib/person/genealogy/StammbaumCard.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumCard.svelte.test.ts @@ -111,17 +111,21 @@ describe('StammbaumCard', () => { expect(items.length).toBeGreaterThanOrEqual(2); }); - it('renders the year range "from–to" for a relationship with both years', async () => { + it('renders the date range "from – to" for a relationship with both dates', async () => { render(StammbaumCard, { props: baseProps({ relationships: [ { id: 'r-1', + personId: 'p-1', + relatedPersonId: 'p-x', + personDisplayName: 'Anna', + relatedPersonDisplayName: 'Xavier', relationType: 'COLLEAGUE', - fromYear: 1940, - toYear: 1945, - personA: { id: 'p-1', displayName: 'Anna' }, - personB: { id: 'p-x', displayName: 'Xavier' } + fromDate: '1940-01-01', + fromDatePrecision: 'YEAR', + toDate: '1945-01-01', + toDatePrecision: 'YEAR' } ] }) @@ -131,23 +135,27 @@ describe('StammbaumCard', () => { expect(document.body.textContent).toContain('1945'); }); - it('renders only "fromYear" for a relationship with no end year', async () => { + it('renders only the start date for a relationship with no end date', async () => { render(StammbaumCard, { props: baseProps({ relationships: [ { id: 'r-2', + personId: 'p-1', + relatedPersonId: 'p-y', + personDisplayName: 'Anna', + relatedPersonDisplayName: 'Yvonne', relationType: 'NEIGHBOR', - fromYear: 1935, - personA: { id: 'p-1', displayName: 'Anna' }, - personB: { id: 'p-y', displayName: 'Yvonne' } + fromDate: '1935-01-01', + fromDatePrecision: 'YEAR', + toDatePrecision: 'UNKNOWN' } ] }) }); expect(document.body.textContent).toContain('1935'); - expect(document.body.textContent).not.toContain('1935–'); + expect(document.body.textContent).not.toContain('1935 –'); }); it('renders the inferred-relationships disclosure when topDerived has items', async () => { diff --git a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte index d60c85fd..9e37dbe1 100644 --- a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte @@ -250,7 +250,7 @@ const parentLinks = $derived.by(() => { y2={bCenter.y} stroke="var(--c-primary)" stroke-width="1.5" - stroke-dasharray={e.toYear ? '4 4' : undefined} + stroke-dasharray={e.toDate ? '4 4' : undefined} /> = { + const body: Record = { relatedPersonId: data.relatedPersonId, relationType: data.relationType }; - if (data.fromYear !== undefined) body.fromYear = data.fromYear; - if (data.toYear !== undefined) body.toYear = data.toYear; + if (data.fromDate) { + body.fromDate = data.fromDate; + if (data.fromDatePrecision) body.fromDatePrecision = data.fromDatePrecision; + } + if (data.toDate) { + body.toDate = data.toDate; + if (data.toDatePrecision) body.toDatePrecision = data.toDatePrecision; + } + if (data.notes) body.notes = data.notes; const res = await csrfFetch(`/api/persons/${node.id}/relationships`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts index 142ccce2..fa991b91 100644 --- a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts +++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts @@ -72,20 +72,20 @@ describe('StammbaumSidePanel', () => { await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); }); - it('year inputs inside the add form have label elements (canWrite=true)', async () => { + it('date inputs inside the add form have accessible labels (canWrite=true)', async () => { render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true }); await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); const addBtn = [...document.querySelectorAll('button')].find((b) => /Beziehung hinzufügen/i.test(b.textContent ?? '') ); addBtn!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); - const yearInputs = [...document.querySelectorAll('input')].filter( + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + const dateInputs = [...document.querySelectorAll('input')].filter( (i) => i.inputMode === 'numeric' ); - expect(yearInputs.length).toBeGreaterThan(0); - for (const input of yearInputs) { - expect(input.closest('label')).not.toBeNull(); + expect(dateInputs.length).toBeGreaterThan(0); + for (const input of dateInputs) { + expect(input.getAttribute('aria-label')).toBeTruthy(); } }); diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 409459ea..1df0305a 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte'; import StammbaumTree from './StammbaumTree.svelte'; import type { PanZoomState } from './panZoom'; import { DIMMED_OPACITY } from './layout/highlightLineage'; +import type { components } from '$lib/generated/api'; + +type RelationshipDTO = components['schemas']['RelationshipDTO']; const ID_A = '00000000-0000-0000-0000-000000000001'; const ID_B = '00000000-0000-0000-0000-000000000002'; @@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: PARENT_B, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1a', @@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD_1, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1b', @@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD_1, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2a', @@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD_2, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2b', @@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD_2, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: PARENT_B, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1', @@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2', @@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: EUGENIE, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1', @@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: HANS, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2', @@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: HANS, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p3', @@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CLARA, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p4', @@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CLARA, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 's2', @@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: HILDE, personDisplayName: 'Hans', relatedPersonDisplayName: 'Hilde', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p5', @@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: LILI, personDisplayName: 'Hans', relatedPersonDisplayName: 'Lili', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p6', @@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: LILI, personDisplayName: 'Hilde', relatedPersonDisplayName: 'Lili', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', relationType: 'SPOUSE_OF', - toYear: 1925 + fromDatePrecision: 'UNKNOWN', + toDate: '1925-01-01', + toDatePrecision: 'YEAR' } ], selectedId: null, @@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { relatedPersonId: CHILD, personDisplayName: 'Parent', relatedPersonDisplayName: 'Child', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => { personDisplayName: string; relatedPersonDisplayName: string; relationType: 'PARENT_OF' | 'SPOUSE_OF'; + fromDatePrecision: 'UNKNOWN'; + toDatePrecision: 'UNKNOWN'; }; const edge = ( personId: string, @@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => { relatedPersonId, personDisplayName: '', relatedPersonDisplayName: '', - relationType + relationType, + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }); const NODES = [ @@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => { // year, then a deterministic id tie-break), not alphabetically — with no birth // years here Walter (id …a1) owns the run and Eugenie sits to his right. So the // deterministic visual order is Walter, Eugenie (top row) then Clara, Hans. - const FAMILY_EDGES = [ + const FAMILY_EDGES: RelationshipDTO[] = [ { id: 'sp', personId: WALTER, relatedPersonId: EUGENIE, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1', @@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => { relatedPersonId: CLARA, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2', @@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => { relatedPersonId: CLARA, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p3', @@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => { relatedPersonId: HANS, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p4', @@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => { relatedPersonId: HANS, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ]; diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index d03da269..ac5d9185 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -42,7 +42,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId): relatedPersonId: childId, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -53,7 +55,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO { relatedPersonId: b, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => { fromYear: number | undefined, id = a + b ): RelationshipDTO { - return { ...spouseEdge(a, b, id), fromYear }; + return { + ...spouseEdge(a, b, id), + ...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {}) + }; } it('multi_spouses_ordered_by_fromYear_then_displayName', () => { @@ -329,7 +336,7 @@ describe('buildLayout — multi-spouse ordering (#361)', () => { // fail fast instead so the maintainer either updates the test or // splits into a year-branch / name-branch pair. const spouseEdgesWithYear = fixtureEdges.filter( - (e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null + (e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null ); expect( spouseEdgesWithYear, diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts index 2a1ace57..5aa04dfb 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts @@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO { relatedPersonId: c, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -33,7 +35,9 @@ function spouse(a: string, b: string, fromYear?: number): RelationshipDTO { personDisplayName: '', relatedPersonDisplayName: '', relationType: 'SPOUSE_OF', - ...(fromYear != null ? { fromYear } : {}) + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN', + ...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {}) }; } diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.ts b/frontend/src/lib/person/genealogy/layout/familyForest.ts index 0663ebd1..6d1f94e2 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.ts @@ -82,7 +82,10 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO } else if (e.relationType === 'SPOUSE_OF') { addToSet(spouses, e.personId, e.relatedPersonId); addToSet(spouses, e.relatedPersonId, e.personId); - spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined); + spouseYear.set( + pairKey(e.personId, e.relatedPersonId), + e.fromDate ? Number(e.fromDate.slice(0, 4)) : undefined + ); } } diff --git a/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts b/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts index 403be550..f9c5301f 100644 --- a/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts +++ b/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts @@ -13,7 +13,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId): relatedPersonId: childId, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -24,7 +26,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO { relatedPersonId: b, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -35,7 +39,9 @@ function siblingEdge(a: string, b: string, id = a + b): RelationshipDTO { relatedPersonId: b, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'SIBLING_OF' + relationType: 'SIBLING_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } diff --git a/frontend/src/lib/person/personLifeDates.ts b/frontend/src/lib/person/personLifeDates.ts index c6d72adc..05af302a 100644 --- a/frontend/src/lib/person/personLifeDates.ts +++ b/frontend/src/lib/person/personLifeDates.ts @@ -1,23 +1,17 @@ -import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; +import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate'; /** - * 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. + * Formats one life date (birth or death) at the precision the data claims. + * Thin domain alias over the shared {@link formatDatePart}: carries no * / † + * glyph — components that need the glyphs wrap them in their own `aria-hidden` + * markup so screen readers only hear the date. */ export function formatLifeDate( date: string | null | undefined, precision: DatePrecision | null | undefined, locale?: string ): string { - if (!date) { - return ''; - } - return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale); + return formatDatePart(date, precision, locale); } /** diff --git a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte index 07fe0e47..78c9451c 100644 --- a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte +++ b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte @@ -1,8 +1,11 @@ @@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) { compact /> - - +
    + + +
    + {#if selfError} {/if} @@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) { {/snippet} @@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) { {:else}
    { + submitting = true; return async ({ result, update }) => { await update(); + submitting = false; if (result.type === 'success') { - open = false; - reset(); + if (isEdit) { + onClose?.(); + } else { + open = false; + reset(); + } } }; }} class="mt-3 rounded-sm border border-line bg-muted/40 p-3" > + {#if relationship} + + {/if} {@render formFields()}
    {/if} diff --git a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.spec.ts b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.spec.ts index da9be13d..8e2e9feb 100644 --- a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.spec.ts +++ b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.spec.ts @@ -8,58 +8,116 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null })); afterEach(cleanup); -describe('AddRelationshipForm', () => { - it('shows add-relationship button initially and no form', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); +const PID = 'person-1'; +const OTHER = 'person-2'; + +const editRel = () => ({ + id: 'rel-9', + personId: PID, + relatedPersonId: OTHER, + personDisplayName: 'Anna Müller', + relatedPersonDisplayName: 'Hans Müller', + relationType: 'SPOUSE_OF' as const, + fromDate: '1923-05-12', + fromDatePrecision: 'DAY' as const, + toDatePrecision: 'UNKNOWN' as const, + notes: 'Hochzeit in Berlin' +}); + +describe('AddRelationshipForm — create mode', () => { + it('shows the add-relationship toggle initially and no form', async () => { + render(AddRelationshipForm, { personId: PID }); await expect.element(page.getByRole('button')).toBeInTheDocument(); - await expect.element(page.getByRole('combobox')).not.toBeInTheDocument(); + expect(document.querySelector('select[name="relationType"]')).toBeNull(); }); - it('shows relationType select when add button is clicked', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); + it('shows the relationType select when the add toggle is clicked', async () => { + render(AddRelationshipForm, { personId: PID }); document.querySelector('button')!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); }); - it('hides form and shows button when cancel is clicked', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); + it('hides the form and shows the toggle again on cancel', async () => { + render(AddRelationshipForm, { personId: PID }); document.querySelector('button')!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); const cancelBtn = [...document.querySelectorAll('button')].find( (b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '') ); cancelBtn!.click(); - await expect.element(page.getByRole('combobox')).not.toBeInTheDocument(); + await vi.waitFor(() => + expect(document.querySelector('select[name="relationType"]')).toBeNull() + ); }); - it('submit is disabled when no person is selected', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); + it('disables submit when no person is selected', async () => { + render(AddRelationshipForm, { personId: PID }); document.querySelector('button')!.click(); await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled(); }); - it('form has no server action when onSubmit prop is provided', async () => { + it('has no server action when an onSubmit prop is provided', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - render(AddRelationshipForm, { personId: 'person-1', onSubmit }); + render(AddRelationshipForm, { personId: PID, onSubmit }); document.querySelector('button')!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); - const form = document.querySelector('form'); - expect(form?.hasAttribute('action')).toBe(false); - }); - - it('shows year-range error when toYear is before fromYear', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); - document.querySelector('button')!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); - - const fromInput = document.querySelector('input[name="fromYear"]')!; - fromInput.value = '1935'; - fromInput.dispatchEvent(new InputEvent('input', { bubbles: true })); - - const toInput = document.querySelector('input[name="toYear"]')!; - toInput.value = '1920'; - toInput.dispatchEvent(new InputEvent('input', { bubbles: true })); - - await expect.element(page.getByRole('alert')).toBeVisible(); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + expect(document.querySelector('form')?.hasAttribute('action')).toBe(false); + }); +}); + +describe('AddRelationshipForm — edit mode', () => { + it('opens pre-filled and labels the submit "Speichern"', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument(); + }); + + it('pre-fills the from-date as dd.mm.yyyy', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + const fromInput = document.querySelector('#fromDate')!; + await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923')); + }); + + it('round-trips the notes into the textarea', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + const notes = document.querySelector('textarea[name="notes"]')!; + await vi.waitFor(() => expect(notes.value).toBe('Hochzeit in Berlin')); + }); + + it('offers only DAY/MONTH/YEAR in each precision select', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + const options = [ + ...document.querySelectorAll('#fromDatePrecision option') + ].map((o) => o.value); + expect(options).toEqual(['DAY', 'MONTH', 'YEAR']); + }); + + it('gives each date input an associated label (accessible name)', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + expect(document.querySelector('#fromDate')?.getAttribute('aria-label')).toBe('Beginn (Datum)'); + expect(document.querySelector('#toDate')?.getAttribute('aria-label')).toBe('Ende (Datum)'); + }); + + it('disables the submit and shows a progress spinner while a submit is in flight', async () => { + let resolve: () => void = () => {}; + const onSubmit = vi.fn(() => new Promise((r) => (resolve = r))); + render(AddRelationshipForm, { personId: PID, relationship: editRel(), onSubmit }); + + const submit = await vi.waitFor(() => { + const b = [...document.querySelectorAll('button')].find( + (x) => x.type === 'submit' + ); + if (!b) throw new Error('submit not ready'); + return b; + }); + submit.click(); + + await expect.element(page.getByTestId('submit-spinner')).toBeInTheDocument(); + await vi.waitFor(() => expect(submit.disabled).toBe(true)); + expect(onSubmit).toHaveBeenCalledOnce(); + resolve(); }); }); diff --git a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.test.ts b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.test.ts index 4e530518..974909a0 100644 --- a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.test.ts +++ b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.test.ts @@ -33,36 +33,6 @@ describe('AddRelationshipForm', () => { expect(optionValues).toContain('OTHER'); }); - it('shows the year-error alert when toYear is before fromYear', async () => { - render(AddRelationshipForm, { props: { personId: 'p-1' } }); - - await page.getByRole('button', { name: /hinzufügen/i }).click(); - - const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement; - const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement; - fromInput.value = '1923'; - fromInput.dispatchEvent(new Event('input', { bubbles: true })); - toInput.value = '1920'; - toInput.dispatchEvent(new Event('input', { bubbles: true })); - - await expect.element(page.getByText(/bis-jahr darf nicht vor von-jahr/i)).toBeVisible(); - }); - - it('does not show the year-error when toYear equals fromYear', async () => { - render(AddRelationshipForm, { props: { personId: 'p-1' } }); - - await page.getByRole('button', { name: /hinzufügen/i }).click(); - - const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement; - const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement; - fromInput.value = '1923'; - fromInput.dispatchEvent(new Event('input', { bubbles: true })); - toInput.value = '1923'; - toInput.dispatchEvent(new Event('input', { bubbles: true })); - - await expect.element(page.getByText(/bis-jahr darf nicht/i)).not.toBeInTheDocument(); - }); - it('cancel button closes the form', async () => { render(AddRelationshipForm, { props: { personId: 'p-1' } }); @@ -136,25 +106,4 @@ describe('AddRelationshipForm', () => { expect(submitBtn!.disabled).toBe(true); }); }); - - it('keeps submit disabled when there is a yearError', async () => { - render(AddRelationshipForm, { props: { personId: 'p-1' } }); - - await page.getByRole('button', { name: /hinzufügen/i }).click(); - - const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement; - const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement; - const relInput = document.querySelector('input[name="relatedPersonId"]') as HTMLInputElement; - fromInput.value = '1923'; - fromInput.dispatchEvent(new Event('input', { bubbles: true })); - toInput.value = '1920'; - toInput.dispatchEvent(new Event('input', { bubbles: true })); - relInput.value = 'p-other'; - relInput.dispatchEvent(new Event('input', { bubbles: true })); - - await vi.waitFor(() => { - const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement; - expect(submitBtn.disabled).toBe(true); - }); - }); }); diff --git a/frontend/src/lib/person/relationship/RelationshipChip.svelte b/frontend/src/lib/person/relationship/RelationshipChip.svelte index 852b0aaa..0bc05c0d 100644 --- a/frontend/src/lib/person/relationship/RelationshipChip.svelte +++ b/frontend/src/lib/person/relationship/RelationshipChip.svelte @@ -5,12 +5,13 @@ import { m } from '$lib/paraglide/messages.js'; interface Props { chipLabel: string; otherName: string; - yearRange?: string; + dateRange?: string; canWrite: boolean; relId: string; + onEdit?: () => void; } -let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props(); +let { chipLabel, otherName, dateRange = '', canWrite, relId, onEdit }: Props = $props();
  • @@ -22,8 +23,31 @@ let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props(); {otherName} - {#if yearRange} - {yearRange} + {#if dateRange} + {dateRange} + {/if} + {#if canWrite && onEdit} + {/if} {#if canWrite}
    diff --git a/frontend/src/lib/person/relationship/RelationshipChip.svelte.spec.ts b/frontend/src/lib/person/relationship/RelationshipChip.svelte.spec.ts index 9c85a6a6..a82e4a27 100644 --- a/frontend/src/lib/person/relationship/RelationshipChip.svelte.spec.ts +++ b/frontend/src/lib/person/relationship/RelationshipChip.svelte.spec.ts @@ -10,7 +10,7 @@ afterEach(cleanup); const baseProps = { chipLabel: 'Elternteil', otherName: 'Anna Schmidt', - yearRange: '', + dateRange: '', canWrite: false, relId: 'rel-1' }; @@ -26,30 +26,55 @@ describe('RelationshipChip', () => { await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); }); - it('shows year range when provided', async () => { - render(RelationshipChip, { ...baseProps, yearRange: '1920–1980' }); - await expect.element(page.getByText('1920–1980')).toBeInTheDocument(); + it('shows the date range when provided', async () => { + render(RelationshipChip, { ...baseProps, dateRange: '12. Mai 1923 – 1958' }); + await expect.element(page.getByText('12. Mai 1923 – 1958')).toBeInTheDocument(); }); - it('does not show year range span when empty', async () => { - render(RelationshipChip, { ...baseProps, yearRange: '' }); - expect(document.querySelector('[data-testid="year-range"]')).toBeNull(); + it('does not render a date-range span when empty', async () => { + render(RelationshipChip, { ...baseProps, dateRange: '' }); + expect(document.querySelector('[data-testid="date-range"]')).toBeNull(); }); - it('shows delete button when canWrite is true', async () => { + it('shows the delete button when canWrite is true', async () => { render(RelationshipChip, { ...baseProps, canWrite: true }); await expect.element(page.getByRole('button')).toBeInTheDocument(); }); - it('hides delete button when canWrite is false', async () => { + it('hides the delete button when canWrite is false', async () => { render(RelationshipChip, { ...baseProps, canWrite: false }); expect(document.querySelector('button')).toBeNull(); }); - it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => { + it('gives the delete button an h-11 w-11 (44px) WCAG touch target', async () => { render(RelationshipChip, { ...baseProps, canWrite: true }); const btn = document.querySelector('button')!; expect(btn.className).toContain('h-11'); expect(btn.className).toContain('w-11'); }); + + it('shows an Edit affordance with an accessible name when canWrite and onEdit', async () => { + render(RelationshipChip, { ...baseProps, canWrite: true, onEdit: () => {} }); + await expect + .element(page.getByRole('button', { name: /Beziehung bearbeiten/i })) + .toBeInTheDocument(); + }); + + it('does not show the Edit affordance without onEdit', async () => { + render(RelationshipChip, { ...baseProps, canWrite: true }); + expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull(); + }); + + it('does not show the Edit affordance when canWrite is false', async () => { + render(RelationshipChip, { ...baseProps, canWrite: false, onEdit: () => {} }); + expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull(); + }); + + it('calls onEdit when the Edit affordance is clicked', async () => { + const onEdit = vi.fn(); + render(RelationshipChip, { ...baseProps, canWrite: true, onEdit }); + const editBtn = document.querySelector('button[aria-label*="bearbeiten"]')!; + editBtn.click(); + expect(onEdit).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/lib/person/relationship/RelationshipDateField.svelte b/frontend/src/lib/person/relationship/RelationshipDateField.svelte new file mode 100644 index 00000000..0b15a920 --- /dev/null +++ b/frontend/src/lib/person/relationship/RelationshipDateField.svelte @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/lib/person/relationshipDates.spec.ts b/frontend/src/lib/person/relationshipDates.spec.ts new file mode 100644 index 00000000..de990bcb --- /dev/null +++ b/frontend/src/lib/person/relationshipDates.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { formatRelationshipDateRange } from './relationshipDates'; + +// Delegates all precision rendering to formatDocumentDate — these tests pin the +// composition (dash, single sides, empty state) and one rendering per precision, +// plus en/es for DAY/MONTH so a German-month leak is caught here, not on a card. +describe('formatRelationshipDateRange', () => { + describe('both dates (de default)', () => { + it('renders DAY precision as full dates', () => { + expect(formatRelationshipDateRange('1923-05-12', 'DAY', '1958-06-13', 'DAY')).toBe( + '12. Mai 1923 – 13. Juni 1958' + ); + }); + + it('renders MONTH precision as month + year', () => { + expect(formatRelationshipDateRange('1923-05-01', 'MONTH', '1958-06-01', 'MONTH')).toBe( + 'Mai 1923 – Juni 1958' + ); + }); + + it('renders YEAR precision as bare years', () => { + expect(formatRelationshipDateRange('1923-01-01', 'YEAR', '1958-01-01', 'YEAR')).toBe( + '1923 – 1958' + ); + }); + + it('renders mixed precisions per side', () => { + expect(formatRelationshipDateRange('1923-05-12', 'DAY', '1958-01-01', 'YEAR')).toBe( + '12. Mai 1923 – 1958' + ); + }); + }); + + describe('single sides and empty states', () => { + it('renders from only without a trailing dash', () => { + expect(formatRelationshipDateRange('1923-05-12', 'DAY', null, null)).toBe('12. Mai 1923'); + }); + + it('renders to only with a leading dash', () => { + expect(formatRelationshipDateRange(null, null, '1958-06-13', 'DAY')).toBe('– 13. Juni 1958'); + }); + + it('renders nothing when both dates are missing (UNKNOWN)', () => { + expect(formatRelationshipDateRange(null, 'UNKNOWN', null, 'UNKNOWN')).toBe(''); + }); + + it('renders nothing for a from-only with a null date', () => { + expect(formatRelationshipDateRange(null, null, null, null)).toBe(''); + }); + }); + + describe('localized months (catch German-month leak)', () => { + it('renders DAY in English with no German month name', () => { + const out = formatRelationshipDateRange('1923-05-12', 'DAY', null, null, 'en'); + expect(out).toContain('May'); + expect(out).not.toContain('Mai'); + expect(out).toContain('1923'); + }); + + it('renders MONTH in Spanish', () => { + const out = formatRelationshipDateRange('1923-05-01', 'MONTH', null, null, 'es'); + expect(out.toLowerCase()).toContain('mayo'); + }); + }); +}); diff --git a/frontend/src/lib/person/relationshipDates.ts b/frontend/src/lib/person/relationshipDates.ts new file mode 100644 index 00000000..76bc79f9 --- /dev/null +++ b/frontend/src/lib/person/relationshipDates.ts @@ -0,0 +1,30 @@ +import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate'; + +/** + * Formats a relationship's start–end range as plain text, e.g. for a marriage row. + * Examples (de): + * 12. Mai 1923 – 13. Juni 1958 (both) + * 12. Mai 1923 (start only — no trailing dash) + * – 13. Juni 1958 (end only) + * "" (neither — the caller renders no date line) + */ +export function formatRelationshipDateRange( + fromDate: string | null | undefined, + fromDatePrecision: DatePrecision | null | undefined, + toDate: string | null | undefined, + toDatePrecision: DatePrecision | null | undefined, + locale?: string +): string { + const from = formatDatePart(fromDate, fromDatePrecision, locale); + const to = formatDatePart(toDate, toDatePrecision, locale); + if (from && to) { + return `${from} – ${to}`; + } + if (from) { + return from; + } + if (to) { + return `– ${to}`; + } + return ''; +} diff --git a/frontend/src/lib/person/relationshipLabels.test.ts b/frontend/src/lib/person/relationshipLabels.test.ts index a62033a7..8dc24923 100644 --- a/frontend/src/lib/person/relationshipLabels.test.ts +++ b/frontend/src/lib/person/relationshipLabels.test.ts @@ -19,6 +19,8 @@ function makeRel( personDisplayName: 'Alice', relatedPersonDisplayName: 'Bob', relationType, + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN', ...override }; } diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index 9bd6fd66..41935c7d 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -10,6 +10,7 @@ export type ErrorCode = | 'INVALID_PERSON_TYPE' | 'BIRTH_AFTER_DEATH' | 'INVALID_DATE_PRECISION' + | 'INVALID_RELATIONSHIP_DATES' | 'INVALID_DATE_RANGE' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' @@ -106,6 +107,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_birth_after_death(); case 'INVALID_DATE_PRECISION': return m.error_invalid_date_precision(); + case 'INVALID_RELATIONSHIP_DATES': + return m.error_invalid_relationship_dates(); case 'INVALID_DATE_RANGE': return m.error_invalid_date_range(); case 'DOCUMENT_NOT_FOUND': diff --git a/frontend/src/lib/shared/primitives/DateInputWithPrecision.svelte b/frontend/src/lib/shared/primitives/DateInputWithPrecision.svelte new file mode 100644 index 00000000..d8782117 --- /dev/null +++ b/frontend/src/lib/shared/primitives/DateInputWithPrecision.svelte @@ -0,0 +1,106 @@ + + +
    + + {legend} + +
    +
    + + {#if errorMessage} +

    {errorMessage}

    + {/if} +
    +
    + +
    +
    +

    {hint}

    +
    diff --git a/frontend/src/lib/shared/utils/documentDate.ts b/frontend/src/lib/shared/utils/documentDate.ts index d46f6346..01143892 100644 --- a/frontend/src/lib/shared/utils/documentDate.ts +++ b/frontend/src/lib/shared/utils/documentDate.ts @@ -66,6 +66,27 @@ export function formatDocumentDate( } } +/** + * Formats one nullable date at the precision the data claims, delegating all + * rendering to {@link formatDocumentDate}. Returns '' for a missing date; a + * missing precision falls back to YEAR — pre-precision rows knew only a year, + * and a bare year is the only safe rendering without precision metadata. + * + * This is the shared core of {@link formatLifeDate} (person birth/death) and the + * relationship from/to formatter. Range-level glyphs and dashes belong in those + * domain wrappers, never here. + */ +export function formatDatePart( + date: string | null | undefined, + precision: DatePrecision | null | undefined, + locale?: string +): string { + if (!date) { + return ''; + } + return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale); +} + // ─── precision branches ────────────────────────────────────────────────────── function longDate(iso: string, locale: string): string { diff --git a/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte b/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte index 389cd894..a7d6549f 100644 --- a/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte +++ b/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte @@ -1,6 +1,7 @@