From 663bb57334cb0a03d714bbb4718106d40b806110 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 19:29:08 +0200 Subject: [PATCH] docs(relationship): ADR-044, DB diagrams, deploy runbook, RTM rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-044 extends ADR-039 to the relationship edge: LocalDate+DatePrecision, update re-validation of create invariants, no @Version (last-write-wins), DELETE→404 anti-enumeration alignment, precise derived marriage date, and the relationshipDates.ts location reusing the existing person→shared boundary. - db-orm.puml: person_relationships now carries from_date/from_date_precision/ to_date/to_date_precision; db-relationships.puml gets a V78 columns-only note. - DEPLOYMENT.md §5: V78 deploy note — no maintenance window, stop-old-then-start ordering (not rolling-deploy-safe), targeted pg_restore rollback. - CLAUDE.md error-code list gains INVALID_RELATIONSHIP_DATES. - rtm.md: REQ-001..REQ-019 for #837 mapped to impl + tests, all Done. Refs #837 Co-Authored-By: Claude Opus 4.8 --- .specify/rtm.md | 19 ++++ CLAUDE.md | 4 +- docs/DEPLOYMENT.md | 23 +++++ ...-relationship-dates-localdate-precision.md | 91 +++++++++++++++++++ docs/architecture/db/db-orm.puml | 10 +- docs/architecture/db/db-relationships.puml | 2 + 6 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 docs/adr/044-relationship-dates-localdate-precision.md diff --git a/.specify/rtm.md b/.specify/rtm.md index f603eb2e..3b5a59d1 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -139,3 +139,22 @@ | 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 | 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/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