As a family curator I want to edit a relationship's type, people, precise dates and notes after creating it, so I can fix mistakes and add dates I learn later without deleting and re-adding #837
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Milestone: Zeitstrahl — Family Timeline · Follow-up to #773
Spec: issue body is the source of truth (SDD; no committed spec.md)
Follow-up to #773, which migrated
Personbirth/death toLocalDate + DatePrecisionand explicitly left "PersonRelationship.fromYear(marriage) staysInteger/YEARfor now" out of scope. This is that deferred work, bundled with the missing edit capability.Context
Constitution principles this feature depends on:
PUTcontroller method callsRelationshipService, never the repository (RelationshipControlleris not exempt from ArchUnit).findAllSpouseEdges()) goes through the relationship/person service, never another domain's repository.PUTcarries@RequirePermission(Permission.WRITE_ALL); there is no unguarded mutating endpoint.PUTis covered by Unwanted-behavior (EARSIf) requirements for both the unauthenticated (401, REQ-018) and unauthorized (403, REQ-005) cases.notesis user-supplied text; it renders through Svelte's default{...}escaping and never{@html}.@Schema(requiredMode = REQUIRED), andnpm run generate:apiis run after the model change.ErrorCode.INVALID_RELATIONSHIP_DATESis added in all four sites (ErrorCode.java,frontend/src/lib/shared/errors.ts,getErrorMessage(),messages/{de,en,es}.json).A
PersonRelationship(marriageSPOUSE_OF,SIBLING_OF,PARENT_OF, plusFRIEND/COLLEAGUE/EMPLOYER/DOCTOR/NEIGHBOR/OTHER) today supports only create (POST /api/persons/{id}/relationships) and delete (DELETE .../{relId}). There is no update endpoint and no edit UI: to fix a wrong type, a wrong person, or to add a date learned later, you must delete the relationship and re-create it — losing the originalcreatedAtand risking the whole edge.Two adjacent gaps compound it:
Integer fromYear/toYear. A wedding can never be more precise than1923, whilePerson,Document, andTimelineEventalready carry fullDatePrecision.notescolumn exists on the entity (@Column(length = 2000)) that no form can set and nothing displays — a dead feature.This issue makes relationships fully editable (type, related person, dates, notes), migrates their dates to
LocalDate + DatePrecisionmirroring #773's ADR-039 person pattern, activatesnotes, and surfaces dates on the read view. As a bonus, the Zeitstrahl's derived Heirat (marriage) events — currently sourced fromSPOUSE_OF.fromYearviaRelationshipService.findAllSpouseEdges()— gain full date precision for free.User Journey (happy path)
1923(YEAR) to12.05.1923(DAY), add a note, and save.12. Mai 1923to any reader.12. Mai 1923instead of1923.Scope — Data Model Change
Replace the two integer year columns on
person_relationshipswith two date + precision pairs, mirroringPerson(ADR-039):fromDateLocalDate(nullable)fromDatePrecisionDatePrecisionNOT NULLUNKNOWN;UNKNOWN⇔ no datetoDateLocalDate(nullable)toDatePrecisionDatePrecisionNOT NULLUNKNOWNReuse the
DatePrecisionenum fromdocument/DatePrecision.java(cross-domain value-type import, already blessed by ADR-039 — nocommon/package). The form exposes DAY / MONTH / YEAR only (resolved — consistent with the person form and the 60+ author audience); storage accepts all 7 values, andSEASON/RANGE/APPROXrender correctly if present from any source but are not offered in the relationship form.Out of scope:
RelationshipInferenceServiceat query time (unchanged).@Version— last-write-wins, matching person edit.StammbaumSidePanel) edit affordance — stays create-only for now.Data Model — Flyway
V78__relationship_years_to_localdate.sqlSingle atomic file (next free number confirmed on disk: latest is
V77__add_timeline_events.sql). Steps:RAISE EXCEPTIONif any row hasfrom_year > to_year, orfrom_year = 0/to_year = 0, naming the offending count.from_date,from_date_precision NOT NULL DEFAULT 'UNKNOWN',to_date,to_date_precision 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(same forto_year).chk_relationship_from_coherence CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN'))chk_relationship_to_coherence CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN'))chk_relationship_date_order CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date)chk_relationship_from_precision_values/chk_relationship_to_precision_values—IN ('DAY','MONTH','SEASON','YEAR','RANGE','APPROX','UNKNOWN')from_year,to_year.One-way migration; rollback via targeted
pg_restore -t person_relationshipsfrom the pre-deploy backup. No maintenance window (single-writer archive). Note in the deploy runbook.Entity
PersonRelationship.java— replaceInteger fromYear/toYearwith:(
PersonRelationshiphas no@Versionand gains none — see Decisions Resolved.)API
New endpoint —
PUT /api/persons/{id}/relationships/{relId}·@RequirePermission(Permission.WRITE_ALL)· returnsRelationshipDTO(200).RelationshipControlleris not exempt from ArchUnit rules; the controller must call the service, not the repository.Request DTO — replace
CreateRelationshipRequest'sfromYear/toYearand reuse the same record for create and update:Response
RelationshipDTO— replaceInteger fromYear/toYearwithfromDate,fromDatePrecision,toDate,toDatePrecision(thepersonBirthYear/relatedPersonBirthYearderived fields are unaffected — they come fromPerson). After the entity/DTO change, runnpm run generate:api; TypeScript compile errors then reveal every caller (StammbaumCard.svelte,RelationshipChip.svelte,PersonRelationshipsCard.svelte, and the timeline path).Service Logic
validateRelationshipDates(replacesvalidateYears): coherence (date present ⇔ precision ≠ UNKNOWN) for both ends →INVALID_DATE_PRECISION(400, reused from #773); order (toDate.isAfter(fromDate)) → newINVALID_RELATIONSHIP_DATES(400). UseDomainException.badRequest/conflict— never raw exceptions.updateRelationship(personId, relId, dto)(@Transactional): load byrelId, respond 404RELATIONSHIP_NOT_FOUNDif it does not belong topersonId; then re-run the same invariants as create — self-relation (VALIDATION_ERROR),validateRelationshipDates, reversePARENT_OF(CIRCULAR_RELATIONSHIP), and the(person, relatedPerson, type)unique constraint viasaveAndFlush(DUPLICATE_RELATIONSHIP); the row's own identity must not self-conflict. If the newrelationTypeis a family type, flag both endpoints as family members (additive, mirrorsaddRelationship; never auto-unflags).addRelationship: switch to the new date fields;notesis already persisted (blankToNull).TimelineEventService.assembleDerivedEvents()derives Heirat fromfindAllSpouseEdges()and currently readsfromYear; update it to sourcefromDate+fromDatePrecision. Derived marriage events then render at full precision.Frontend
relationshipDates.ts(new, mirrors #773'spersonLifeDates.ts):formatRelationshipDateRange(fromDate, fromPrec, toDate, toPrec, locale)delegating entirely to the already-testedformatDocumentDate($lib/shared/utils/documentDate.ts) — zero new precision logic. Lives in$lib/person/(wherepersonLifeDates.tsalready sits); its only cross-domain import isformatDocumentDatefrom$lib/shared/utils/documentDate.ts, which the existingperson → sharedrule ineslint.config.jsalready permits — no boundary change needed.AddRelationshipForm.svelteupsert-capable (pre-fill from an optionalrelationshipprop) or add a siblingEditRelationshipForm.svelte. Replace the two<input name="fromYear/toYear">with two date + precision controls per end, reusing thePersonLifeDateField.sveltepattern with aRELATIONSHIP_DATE_PRECISIONS = ['DAY','MONTH','YEAR']filter. Add a notes<textarea>(≤2000). German date entry via the existinghandleGermanDateInputfrom$lib/shared/utils/date.ts.min-h-[44px]on the precision<select>(WCAG 2.2 touch target). Every date input, the precision<select>, and the notes<textarea>has an associated<label for>; while the request is in flight the submit control is disabled and shows a progress indicator (REQ-019), preventing a double-submit on a slowPUT. The notes<textarea>and the Edit button use the semantic tokens (bg-surface,text-ink-3,border-line) so dark mode is guaranteed, not inherited by assumption fromPersonLifeDateField.svelte.RelationshipChip.svelte: add an Edit button next to Delete, shown only whencanWrite, opening the pre-filled form. If the affordance is icon-only it carries an accessible name (aria-label={m.relation_edit()}).persons/[id]/edit/+page.server.ts: add anupdateRelationshipaction (PUTvia the typed API client), parsing German dates + precision + notes; surface backend 400s as localized form errors (getErrorMessage(extractErrorCode(result.error))). Update theaddRelationshipaction to send the date+precision+notes shape.PersonRelationshipsCard.svelte: render the date range viaformatRelationshipDateRangeand shownotes.StammbaumCard.svelte: replace the integeryearRange()helper withformatRelationshipDateRange.i18n Key Inventory (
messages/{de,en,es}.json)relation_label_from_daterelation_label_to_daterelation_label_date_precisionrelation_precision_dayrelation_precision_monthrelation_precision_yearrelation_label_notesrelation_notes_placeholderrelation_date_placeholder_hintrelation_editerror_invalid_relationship_dates(
error_invalid_date_precisionis reused from #773 — verify the key already exists before re-adding.)Security Considerations (STRIDE)
PUTis rejected with 401 and mutates nothing (REQ-018; CWE-306; constitution §2.8).WRITE_ALLis rejected with 403 on both create and update (REQ-005; §2.1).{relId}that does not belong to{id}is rejected with 404RELATIONSHIP_NOT_FOUND(REQ-006); the service loads byrelIdand verifies ownership before any write, so one curator cannot edit another person's relationship by guessing ids. The 404 (not 403) on thePUTis a deliberate anti-enumeration choice; the existingdeleteRelationshipreturns 403 for the same mismatch, so a task alignsDELETEto 404 for consistency (see Tasks / Open Decisions).updateRelationshiplog lines and theV78pre-checkRAISE EXCEPTIONcarry only stable UUIDs and counts — nevernotes, person names, or other PII (constitution §2.7).notesis user-supplied free text, rendered only through Svelte's default{...}escaping, never{@html}(CWE-79; §2.5). Error bodies carry only a typedErrorCode, no entity internals.createdAtis preserved across edits (the point of update-vs-recreate); audit fields are set from the session principal inside the service, never bound from the request body (§2.4).Requirements (EARS)
LocalDateplus a NOT-NULLDatePrecision(defaultUNKNOWN), replacingfromYear/toYear.V78runs, the system shall convert each non-nullfromYear/toYearto{year}-01-01atYEARprecision and leave null years asnull+UNKNOWN, preserving every existing row.(date IS NULL) = (precision = UNKNOWN)for both ends and thatfromDate <= toDatewhen both are present.PUT /api/persons/{id}/relationships/{relId}with a valid body, the system shall update the relationship and return the updatedRelationshipDTOwith status 200.WRITE_ALL, the system shall reject create and update with 403.{relId}does not exist or does not belong to person{id}, then the system shall respond 404RELATIONSHIP_NOT_FOUND.relatedPersonId == {id}, then the system shall respond 400VALIDATION_ERROR.(person, relatedPerson, relationType)already exists on another row, then the system shall respond 409DUPLICATE_RELATIONSHIP.relationType = PARENT_OFwhile the reversePARENT_OFexists, then the system shall respond 409CIRCULAR_RELATIONSHIP.toDateis beforefromDate, then the system shall respond 400INVALID_RELATIONSHIP_DATES.UNKNOWNprecision, or a non-UNKNOWNprecision is set without a date, then the system shall respond 400INVALID_DATE_PRECISION.notesexceeds 2000 characters, orrelationType/relatedPersonIdis missing or carries an invalid enum value, then the system shall respond 400VALIDATION_ERROR(Bean Validation at the controller boundary).relationTypeis a family type, the system shall ensure both endpoints are flagged family members.noteson create, update, and both the read and edit views.SPOUSE_OFrelationship'sfromDate+fromDatePrecision.PUT /api/persons/{id}/relationships/{relId}is unauthenticated, then the system shall respond 401 and modify no row.PUTcannot be double-submitted.Acceptance Criteria (measurable)
V78, a relationship that hadfromYear=1923, toYear=1958hasfrom_date='1923-01-01'/YEAR,to_date='1958-01-01'/YEAR; thefrom_year/to_yearcolumns no longer exist; row count before == row count after.1923(YEAR) to12.05.1923(DAY) in the form persistsfrom_date='1923-05-12'/DAY, and the detail page renders12. Mai 1923(de) /May 12, 1923(en).PUTwith an unknownrelId(or arelIdbelonging to a different person) → 404RELATIONSHIP_NOT_FOUNDwith a structuredErrorCodein the body.(person, relatedPerson, type)→ 409DUPLICATE_RELATIONSHIP; into a reversePARENT_OF→ 409CIRCULAR_RELATIONSHIP.toDatebeforefromDate→ 400INVALID_RELATIONSHIP_DATES; equal dates allowed.fromDateset withfromDatePrecision=UNKNOWN→ 400INVALID_DATE_PRECISION; precisionDAYwith nullfromDate→ 400.–); a from-only relationship renders just the start, no trailing dash.SPOUSE_OF.fromDateshows that exact date on the Zeitstrahl marriage event, not just the year.StammbaumCard, the family network, andRelationshipInferenceServiceviews render unchanged for YEAR-precision (backfilled) relationships; the inference service is untouched and its tests pass.person_relationshipsrow storesfrom_date/from_date_precision/to_date/to_date_precision; inserting a row with a date present butUNKNOWNprecision is rejected bychk_relationship_*_coherence, and ato_date < from_dateinsert is rejected bychk_relationship_date_order.POSTorPUTfrom a caller withoutWRITE_ALL→ 403, and the row is unchanged.PUTwithrelatedPersonId == {id}→ 400VALIDATION_ERROR.PUTwith an unknownrelationTypeenum value, or a missingrelatedPersonId/relationType, → 400VALIDATION_ERROR;notesof exactly 2000 chars is accepted and 2001 → 400.OTHER) into a family type (e.g.SIBLING_OF) flags both endpoints as family members; an already-flagged endpoint is never unflagged.PUT→ 401, and the row is unchanged.PUTis in flight the submit control is disabled and shows a progress indicator; a second click issues no second request.Tasks
PersonRelationship.java: replacefromYear/toYearwithfromDate/fromDatePrecision/toDate/toDatePrecision.V78__relationship_years_to_localdate.sql: pre-check → add columns → backfillYYYY-01-01/YEAR→ 5 named CHECK constraints → dropfrom_year/to_year.RelationshipUpsertRequest(replaceCreateRelationshipRequest, shared by create + update);RelationshipDTOdate fields.RelationshipController: addPUT /api/persons/{id}/relationships/{relId}(@RequirePermission(WRITE_ALL)).RelationshipService/RelationshipController: aligndeleteRelationship's ownership-mismatch response from 403 to 404RELATIONSHIP_NOT_FOUND, matching the newPUT(anti-enumeration consistency — see Open Decisions).RelationshipService:validateRelationshipDates,updateRelationship, updateaddRelationship; re-run self/circular/duplicate/family-flag on update.ErrorCode.INVALID_RELATIONSHIP_DATES→ mirror tofrontend/src/lib/shared/errors.ts+ acaseingetErrorMessage()+ i18n keys.TimelineEventService: derive Heirat date fromfromDate/fromDatePrecision.npm run generate:api; fix every revealed caller.relationshipDates.ts; upsert-capable form with date+precision+notes;RelationshipChipEdit button;updateRelationshipserver action; read-view date + notes display;StammbaumCardhelper swap.messages/{de,en,es}.json.043-derived-person-events.md): extends ADR-039 to the relationship edge; documents update re-validation of create invariants, the precise derived marriage date on the Zeitstrahl, and the no-@Versiondecision. Also records thatrelationshipDates.tslives in$lib/person/and reuses the existingperson → sharedboundary (no new eslint rule).docs/architecture/db/db-orm.pumlanddb-relationships.puml(merge blocker).from_yearuntil redeploy, so thefrom_year/to_yearcolumn drop is not rolling-deploy-safe); includepg_restore -t person_relationshipsfor targeted rollback.Tests
Migration (Testcontainers
postgres:16-alpine— NOT H2; H2 won't honor CHECK constraints)from_year > to_year; aborts onfrom_year = 0.from_year=1923→from_date='1923-01-01'/YEAR; both-null → null/UNKNOWN (no spurious0001-01-01).to_date < from_dateinsert; coherence CHECK rejectsdate present + UNKNOWN precision.from_yearafter migration errors.Service / controller
validateRelationshipDates: order rejected; equal allowed; null sides allowed; coherence both directions.updateRelationship: happy path; 404 on wrong person;DUPLICATE_RELATIONSHIP; reversePARENT_OF→CIRCULAR_RELATIONSHIP; family-flag set on update to a family type.RelationshipControllerTest:PUT200; 401 when unauthenticated (row unchanged); 403 withoutWRITE_ALL; invalid enum value → 400 with structuredErrorCode; invalid date string → 400.Frontend (
*.spec.ts, browser mode)relationshipDates.spec.ts: DAY/MONTH/YEAR/UNKNOWN × {from-only, to-only, both}, withen/esfor at least DAY/MONTH (catch German-month leak).dd.mm.yyyy; precision select shows only DAY/MONTH/YEAR; notes round-trips; malformed date → localized error (not a Jackson trace); 320px no-overflow; submit control disabled + progress shown while in flight (REQ-019); every input has a<label for>and the icon-only Edit affordance has an accessible name.PersonRelationshipsCard.svelte.spec.ts: read view shows the date range + notes; empty-state shows no date line.Timeline
TimelineEventServiceTest: a DAY-precisionSPOUSE_OF.fromDateproduces a derived Heirat event rendering the exact date.Decisions Resolved
LocalDate+DatePrecisionper end (mirror Person ADR-039)SEASON/APPROXrender but aren't offeredINVALID_RELATIONSHIP_DATES; coherence reusesINVALID_DATE_PRECISIONBIRTH_AFTER_DEATHand documentINVALID_DATE_RANGERelationshipUpsertRequestfor create + updatePARENT_OF/ duplicate / family-flag@Version); last-write-winssetVersionpitfallRelationshipInferenceServicealready derives reverse/sibling edges at query timenotesV77__add_timeline_events.sql,043-derived-person-events.mdOpen Decisions
These carry a concrete proposal the spec already adopts; they are buildable as written and listed only for human confirm/override before or at
/implement:@Version) onPersonRelationship— last-write-wins, matching person edit and avoiding the managed-setVersionpitfall. Adopted (see Decisions Resolved + ADR-044); confirm it is acceptable for the single-writer family archive. (alt: add@Version+ an explicit client-version compare.)DELETEownership-mismatch → 404 — a task aligns the existingdeleteRelationshipfrom 403 to 404RELATIONSHIP_NOT_FOUNDso it matches the newPUT's anti-enumeration behavior. Confirm, or drop the alignment and instead documentPUT's 404 as an intentional divergence. (alt: leaveDELETEat 403.)Traceability (RTM rows — added on the feature branch at
/implement, not on main now)Open Questions
None — OQ-1 (milestone → Zeitstrahl) and OQ-2 (precision set → DAY/MONTH/YEAR only) are resolved (see Decisions Resolved).
Persona Review Results
Converged after 2 rounds of
/review-issue(six-persona SDD spec gate). Round 2: Requirements Engineer · Developer · Security · DevOps · UI/UX · Architect — all APPROVE, no blocking FAILs. Round 1 folds: constitution-principle list, REQ-018 (401 unauthenticated), REQ-019 (in-flight submit lock), Security Considerations (STRIDE), six added acceptance criteria, deploy ordering, helper location/boundary. Round 2 polish: IDOR 404-vs-403 note +DELETEalignment task, PII-log discipline, semantic dark-mode tokens. Items left for human sign-off are in## Open Decisions.✅ Implemented on
feat/issue-837-relationship-edit-datesAll 19 requirements implemented with red/green TDD. Both Open Decisions confirmed as adopted: no
@Version(last-write-wins) andDELETEownership-mismatch aligned 403 → 404.Commits
feat(relationship): add INVALID_RELATIONSHIP_DATES error codefeat(relationship): store from/to as LocalDate + DatePrecisionfeat(relationship): add PUT update endpoint, align DELETE mismatch to 404feat(relationship): add formatRelationshipDateRange helperfeat(relationship): date+precision edit UI, notes, and read-view displaydocs(relationship): ADR-044, DB diagrams, deploy runbook, RTM rowsREQ → test
RelationshipMigrationTest(Testcontainers pg16, 8 tests)RelationshipServiceTest#updateRelationship_*,RelationshipServiceIntegrationTest(real DB)RelationshipControllerTest#updateRelationship_*RelationshipServiceTest#addRelationship_throws_INVALID_RELATIONSHIP_DATES…,…INVALID_DATE_PRECISION…PersonRelationshipsCard.svelte.test.ts,relationshipDates.spec.tsAddRelationshipForm.svelte.spec.ts,RelationshipChip.svelte.spec.tsDerivedEventsAssemblyTest#should_emit_day_precision_heirat_from_spouse_fromDateVerification
RelationshipMigrationTest(8),RelationshipServiceTest(22),RelationshipControllerTest(15),RelationshipServiceIntegrationTest(10, real DB),DerivedEventsAssemblyTest(17),ArchitectureTest(14) — all green;clean packagebuilds.*.spec.ts/*.test.tsgreen (form, chip, card, helper, server actions, message parity);npm run check798 errors (below the ~834 baseline — net-negative);npm run lintclean.api.tsregenerated from the live spec (springdoc reorder noise pruned).AddRelationshipForm(upsert-capable) rather than a duplicateEditRelationshipForm; RTM rows referenceAddRelationshipForm.svelte.spec.tsaccordingly.RTM rows REQ-001…REQ-019 added for #837, all
Done. Ready for/review-pr.