feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s

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 <marcel@familienarchiv>
Reviewed-on: #841
This commit was merged in pull request #841.
This commit is contained in:
2026-06-14 21:17:36 +02:00
parent 6dae4fe428
commit 8558567688
59 changed files with 2196 additions and 580 deletions

View File

@@ -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-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-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-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-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-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 | | 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 |

View File

@@ -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) → 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 ### Security / Permissions
@@ -280,7 +280,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → 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).
--- ---

View File

@@ -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;
}
}

View File

@@ -122,6 +122,8 @@ public enum ErrorCode {
CIRCULAR_RELATIONSHIP, CIRCULAR_RELATIONSHIP,
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */ /** A relationship with the same (person, relatedPerson, type) already exists. 409 */
DUPLICATE_RELATIONSHIP, DUPLICATE_RELATIONSHIP,
/** A relationship's toDate is before its fromDate. 400 */
INVALID_RELATIONSHIP_DATES,
// --- Geschichten (Stories) --- // --- Geschichten (Stories) ---
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */ /** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */

View File

@@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonUpsertCommand; import org.raddatz.familienarchiv.person.PersonUpsertCommand;
import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService; 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 org.springframework.stereotype.Component;
import java.io.File; import java.io.File;
@@ -126,7 +126,7 @@ public class PersonTreeImporter {
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) { private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
try { try {
relationshipService.addRelationship(person, 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; return true;
} catch (DomainException e) { } catch (DomainException e) {
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP

View File

@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO; import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO; import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
@@ -448,41 +449,28 @@ public class PersonService {
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()) .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()) .notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthDate(dto.getBirthDate()) .birthDate(dto.getBirthDate())
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())) .birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate()) .deathDate(dto.getDeathDate())
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision())) .deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
.generation(dto.getGeneration()) .generation(dto.getGeneration())
.build(); .build();
return personRepository.save(person); return personRepository.save(person);
} }
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the // 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, private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) { LocalDate deathDate, DatePrecision deathPrecision) {
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth"); DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(deathDate, deathPrecision, "death"); DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) { if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH, throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
"Birth date " + birthDate + " is after death date " + deathDate); "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 @Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) { public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) { 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.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()); person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthDate(dto.getBirthDate()); person.setBirthDate(dto.getBirthDate());
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())); person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate()); 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 // Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim. // which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration()); person.setGeneration(dto.getGeneration());

View File

@@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@@ -39,11 +41,25 @@ public class PersonRelationship {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private RelationType relationType; private RelationType relationType;
@Column(name = "from_year") // Start/end of the relationship (wedding, employment start, …). The date column
private Integer fromYear; // 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") @Enumerated(EnumType.STRING)
private Integer toYear; @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) @Column(length = 2000)
private String notes; private String notes;

View File

@@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.person.Person; 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.FamilyMemberPatchDTO;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
@@ -63,11 +63,20 @@ public class RelationshipController {
@RequirePermission(Permission.WRITE_ALL) @RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<RelationshipDTO> addRelationship( public ResponseEntity<RelationshipDTO> addRelationship(
@PathVariable UUID id, @PathVariable UUID id,
@Valid @RequestBody CreateRelationshipRequest dto) { @Valid @RequestBody RelationshipUpsertRequest dto) {
return ResponseEntity.status(HttpStatus.CREATED) return ResponseEntity.status(HttpStatus.CREATED)
.body(relationshipService.addRelationship(id, dto)); .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}") @DeleteMapping("/api/persons/{id}/relationships/{relId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL) @RequirePermission(Permission.WRITE_ALL)

View File

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.person.relationship; package org.raddatz.familienarchiv.person.relationship;
import lombok.RequiredArgsConstructor; 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.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person; 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.InferredRelationshipDTO;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
@@ -96,65 +98,129 @@ public class RelationshipService {
} }
@Transactional @Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) { public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) {
if (personId.equals(dto.relatedPersonId())) { requireNotSelf(personId, dto.relatedPersonId());
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
Person person = personService.getById(personId); Person person = personService.getById(personId);
Person relatedPerson = personService.getById(dto.relatedPersonId()); Person relatedPerson = personService.getById(dto.relatedPersonId());
validateYears(dto.fromYear(), dto.toYear()); validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());
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());
}
PersonRelationship rel = PersonRelationship.builder() PersonRelationship rel = PersonRelationship.builder()
.person(person) .person(person)
.relatedPerson(relatedPerson) .relatedPerson(relatedPerson)
.relationType(dto.relationType()) .relationType(dto.relationType())
.fromYear(dto.fromYear()) .fromDate(dto.fromDate())
.toYear(dto.toYear()) .fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
.toDate(dto.toDate())
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
.notes(blankToNull(dto.notes())) .notes(blankToNull(dto.notes()))
.build(); .build();
PersonRelationship saved; PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
try { flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId());
// 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);
}
return toDTO(saved); 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 @Transactional
public void deleteRelationship(UUID personId, UUID relId) { 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) PersonRelationship rel = relationshipRepository.findById(relId)
.orElseThrow(() -> DomainException.notFound( .orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId)); ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
UUID storageSubject = rel.getPerson().getId(); UUID storageSubject = rel.getPerson().getId();
UUID storageObject = rel.getRelatedPerson().getId(); UUID storageObject = rel.getRelatedPerson().getId();
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) { 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); "Relationship " + relId + " does not belong to person " + personId);
} }
relationshipRepository.delete(rel); return rel;
} }
@Transactional @Transactional
@@ -173,10 +239,17 @@ public class RelationshipService {
return date != null ? date.getYear() : null; return date != null ? date.getYear() : null;
} }
private static void validateYears(Integer fromYear, Integer toYear) { // Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
if (fromYear != null && toYear != null && toYear < fromYear) { // user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
throw DomainException.badRequest( // Coherence is shared with the person domain (DatePrecisionValidation); only the order
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear"); // 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.getBirthDate()),
yearOf(rp.getDeathDate()), yearOf(rp.getDeathDate()),
r.getRelationType(), r.getRelationType(),
r.getFromYear(), r.getFromDate(),
r.getToYear(), r.getFromDatePrecision(),
r.getToDate(),
r.getToDatePrecision(),
r.getNotes()); r.getNotes());
} }
} }

View File

@@ -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
) {}

View File

@@ -1,8 +1,10 @@
package org.raddatz.familienarchiv.person.relationship.dto; package org.raddatz.familienarchiv.person.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationType;
import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -26,7 +28,9 @@ public record RelationshipDTO(
Integer relatedPersonBirthYear, Integer relatedPersonBirthYear,
Integer relatedPersonDeathYear, Integer relatedPersonDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
Integer fromYear, LocalDate fromDate,
Integer toYear, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision,
LocalDate toDate,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision,
String notes String notes
) {} ) {}

View File

@@ -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
) {}

View File

@@ -290,13 +290,11 @@ public class TimelineEventService {
List<TimelineEntryDTO> result = new ArrayList<>(); List<TimelineEntryDTO> result = new ArrayList<>();
for (PersonRelationship r : spouseEdges) { for (PersonRelationship r : spouseEdges) {
if (seen.add(r.getId())) { if (seen.add(r.getId())) {
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded // JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded.
LocalDate eventDate = r.getFromYear() != null // The marriage date is the relationship's from_date at its stored precision
? LocalDate.of(r.getFromYear(), 1, 1) // (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year.
: null; LocalDate eventDate = r.getFromDate();
DatePrecision precision = r.getFromYear() != null DatePrecision precision = r.getFromDatePrecision();
? DatePrecision.YEAR
: DatePrecision.UNKNOWN;
String title = r.getPerson().getDisplayName() String title = r.getPerson().getDisplayName()
+ " & " + r.getRelatedPerson().getDisplayName(); + " & " + r.getRelatedPerson().getDisplayName();
result.add(new TimelineEntryDTO( result.add(new TimelineEntryDTO(

View File

@@ -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;

View File

@@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InOrder; import org.mockito.InOrder;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService; import org.raddatz.familienarchiv.person.relationship.RelationshipService;
@@ -169,7 +170,7 @@ class CanonicalImportOrchestratorTest {
RelationshipDTO edge = new RelationshipDTO( RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), parentId, childId, UUID.randomUUID(), parentId, childId,
"Parent", null, null, "Child", null, null, "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()) when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge))); .thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of())); when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));

View File

@@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.PersonUpsertCommand; import org.raddatz.familienarchiv.person.PersonUpsertCommand;
import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -76,7 +76,7 @@ class PersonTreeImporterTest {
new PersonTreeImporter(personService, relationshipService) new PersonTreeImporter(personService, relationshipService)
.load(json.toFile()); .load(json.toFile());
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class); ArgumentCaptor<RelationshipUpsertRequest> captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class);
verify(relationshipService).addRelationship(eq(idA), captor.capture()); verify(relationshipService).addRelationship(eq(idA), captor.capture());
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB); assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF); assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.person.relationship; package org.raddatz.familienarchiv.person.relationship;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; 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.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing; 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.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -98,7 +101,7 @@ class RelationshipControllerTest {
UUID.randomUUID(), PERSON_ID, OTHER_ID, UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", 1900, 1980, "Alice Müller", 1900, 1980,
"Bob Müller", 1930, null, "Bob Müller", 1930, null,
RelationType.PARENT_OF, null, null, null); RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
when(relationshipService.getFamilyNetwork()) when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(node), List.of(edge))); .thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
@@ -139,7 +142,7 @@ class RelationshipControllerTest {
UUID.randomUUID(), PERSON_ID, OTHER_ID, UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", null, null, "Alice Müller", null, null,
"Bob 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); when(relationshipService.addRelationship(any(), any())).thenReturn(created);
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) 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())) mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
.andExpect(status().isNoContent()); .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());
}
} }

View File

@@ -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).
*
* <p>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());
}
}

View File

@@ -4,10 +4,11 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig; import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person; 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.NetworkDTO;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.person.PersonNameAliasRepository; import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
@@ -20,6 +21,7 @@ import org.springframework.context.annotation.Import;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -65,13 +67,17 @@ class RelationshipServiceIntegrationTest {
@Test @Test
void addRelationship_stores_and_is_readable() { 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); RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
assertThat(created.id()).isNotNull(); assertThat(created.id()).isNotNull();
assertThat(created.personId()).isEqualTo(alice.getId()); assertThat(created.personId()).isEqualTo(alice.getId());
assertThat(created.relatedPersonId()).isEqualTo(bob.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<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId()); List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
assertThat(rels).hasSize(1); assertThat(rels).hasSize(1);
@@ -80,7 +86,7 @@ class RelationshipServiceIntegrationTest {
@Test @Test
void addRelationship_throws_409_when_duplicate() { 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); relationshipService.addRelationship(alice.getId(), dto);
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto)) assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
@@ -93,9 +99,9 @@ class RelationshipServiceIntegrationTest {
void addRelationship_throws_409_when_circular_parent() { void addRelationship_throws_409_when_circular_parent() {
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected. // alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
relationshipService.addRelationship(alice.getId(), 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)) assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -103,28 +109,58 @@ class RelationshipServiceIntegrationTest {
} }
@Test @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(), 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())) assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN); .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
// The row is still there. // The row is still there.
assertThat(relationshipRepository.findById(created.id())).isPresent(); 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 @Test
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() { void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF) // 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. // and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
relationshipService.addRelationship(alice.getId(), 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)) assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -135,7 +171,7 @@ class RelationshipServiceIntegrationTest {
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() { void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
// alice SPOUSE_OF bob. Bob deletes from his side. // alice SPOUSE_OF bob. Bob deletes from his side.
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), 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()); relationshipService.deleteRelationship(bob.getId(), created.id());
@@ -148,7 +184,7 @@ class RelationshipServiceIntegrationTest {
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit // 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. // setFamilyMember(true) call below is the thing under test, not the auto-flip.
relationshipService.addRelationship(alice.getId(), 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); relationshipService.setFamilyMember(charlie.getId(), false);
NetworkDTO before = relationshipService.getFamilyNetwork(); NetworkDTO before = relationshipService.getFamilyNetwork();
@@ -165,7 +201,7 @@ class RelationshipServiceIntegrationTest {
@Test @Test
void delete_person_cascades_to_relationships() { void delete_person_cascades_to_relationships() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), 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(); UUID relId = created.id();
assertThat(relationshipRepository.findById(relId)).isPresent(); assertThat(relationshipRepository.findById(relId)).isPresent();

View File

@@ -6,16 +6,18 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person; 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.raddatz.familienarchiv.person.PersonService;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -59,9 +61,9 @@ class RelationshipServiceTest {
charlie = person("Charlie"); charlie = person("Charlie");
} }
// --- Nora blocker 1 --- // --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
@Test @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(); UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId); PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
@@ -69,7 +71,7 @@ class RelationshipServiceTest {
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId)) assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN); .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
verify(relationshipRepository, never()).delete(any()); verify(relationshipRepository, never()).delete(any());
} }
@@ -82,7 +84,7 @@ class RelationshipServiceTest {
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true); 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)) assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -98,7 +100,7 @@ class RelationshipServiceTest {
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false); bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel")); 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)) assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -107,7 +109,7 @@ class RelationshipServiceTest {
@Test @Test
void addRelationship_throws_BAD_REQUEST_when_self_relationship() { 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)) assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -116,14 +118,42 @@ class RelationshipServiceTest {
} }
@Test @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(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob); 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)) assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .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()); verify(relationshipRepository, never()).saveAndFlush(any());
} }
@@ -140,13 +170,16 @@ class RelationshipServiceTest {
return r; 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); var result = service.addRelationship(alice.getId(), dto);
assertThat(result.personId()).isEqualTo(alice.getId()); assertThat(result.personId()).isEqualTo(alice.getId());
assertThat(result.relatedPersonId()).isEqualTo(bob.getId()); assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF); 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"); assertThat(result.notes()).isEqualTo("first born");
} }
@@ -166,7 +199,7 @@ class RelationshipServiceTest {
return r; 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); service.addRelationship(alice.getId(), dto);
verify(personService).setFamilyMember(alice.getId(), true); verify(personService).setFamilyMember(alice.getId(), true);
@@ -187,7 +220,7 @@ class RelationshipServiceTest {
return r; 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); service.addRelationship(alice.getId(), dto);
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean()); verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
@@ -216,6 +249,131 @@ class RelationshipServiceTest {
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); .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 @Test
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() { 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). // 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) { 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() return PersonRelationship.builder()
.id(id) .id(id)
.person(parent) .person(subject)
.relatedPerson(child) .relatedPerson(object)
.relationType(RelationType.PARENT_OF) .relationType(type)
.createdAt(Instant.now()) .createdAt(Instant.now())
.build(); .build();
} }

View File

@@ -81,12 +81,19 @@ class DerivedEventsAssemblyTest {
} }
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) { 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() return PersonRelationship.builder()
.id(UUID.randomUUID()) .id(UUID.randomUUID())
.person(a) .person(a)
.relatedPerson(b) .relatedPerson(b)
.relationType(RelationType.SPOUSE_OF) .relationType(RelationType.SPOUSE_OF)
.fromYear(fromYear) .fromDate(fromDate)
.fromDatePrecision(precision)
.build(); .build();
} }
@@ -223,6 +230,24 @@ class DerivedEventsAssemblyTest {
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN); 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) --- // --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
@Test @Test

View File

@@ -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.) (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 ### 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: 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:

View File

@@ -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).

View File

@@ -1,6 +1,6 @@
@startuml db-orm @startuml db-orm
' Schema source: Flyway V1V77 (excl. V37, V43 — intentionally removed) ' Schema source: Flyway V1V78 (excl. V37, V43 — intentionally removed)
' Schema as of: V77 (2026-06-12) ' Schema as of: V78 (2026-06-14)
' ⚠ This is a versioned snapshot. Update when the schema changes significantly. ' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
hide circle hide circle
@@ -211,8 +211,10 @@ package "Persons" {
person_id : UUID <<FK>> person_id : UUID <<FK>>
related_person_id : UUID <<FK>> related_person_id : UUID <<FK>>
relation_type : VARCHAR(30) NOT NULL relation_type : VARCHAR(30) NOT NULL
from_year : INTEGER from_date : DATE
to_year : INTEGER from_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
to_date : DATE
to_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
notes : VARCHAR(2000) notes : VARCHAR(2000)
created_at : TIMESTAMPTZ NOT NULL created_at : TIMESTAMPTZ NOT NULL
} }

View File

@@ -7,6 +7,8 @@
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date + ' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
' precision columns; columns only, no new FK relationships, diagram unchanged. ' precision columns; columns only, no new FK relationships, diagram unchanged.
' Note: V77 adds the timeline_events table + two join tables (Timeline package below). ' 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 hide circle
skinparam linetype ortho skinparam linetype ortho

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.", "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_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.", "error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
"error_invalid_relationship_dates": "Das Ende-Datum darf nicht vor dem Beginn-Datum liegen.",
"validation_last_name_required": "Nachname ist Pflichtfeld.", "validation_last_name_required": "Nachname ist Pflichtfeld.",
"validation_first_name_required": "Vorname ist Pflichtfeld.", "validation_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", "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_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr", "relation_form_field_to_year": "Bis Jahr",
"relation_form_year_placeholder": "z.B. 1920", "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_heading": "Beziehungen",
"person_relationships_empty": "Noch keine Beziehungen bekannt.", "person_relationships_empty": "Noch keine Beziehungen bekannt.",
"timeline_aria_label": "Zeitachse Dokumentdichte", "timeline_aria_label": "Zeitachse Dokumentdichte",

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "The end date must not be before the start date.", "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_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
"error_invalid_date_precision": "Date and precision do not match.", "error_invalid_date_precision": "Date and precision do not match.",
"error_invalid_relationship_dates": "The end date must not be before the start date.",
"validation_last_name_required": "Last name is required.", "validation_last_name_required": "Last name is required.",
"validation_first_name_required": "First name is required.", "validation_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.", "error_ocr_service_unavailable": "The OCR service is not available.",
@@ -1221,6 +1222,16 @@
"relation_form_field_from_year": "From year", "relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year", "relation_form_field_to_year": "To year",
"relation_form_year_placeholder": "e.g. 1920", "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_heading": "Relationships",
"person_relationships_empty": "No relationships known yet.", "person_relationships_empty": "No relationships known yet.",
"timeline_aria_label": "Document density timeline", "timeline_aria_label": "Document density timeline",

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.", "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_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
"error_invalid_date_precision": "La fecha y la precisión no coinciden.", "error_invalid_date_precision": "La fecha y la precisión no coinciden.",
"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_last_name_required": "El apellido es obligatorio.",
"validation_first_name_required": "El nombre es obligatorio.", "validation_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.", "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_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año", "relation_form_field_to_year": "Hasta año",
"relation_form_year_placeholder": "ej. 1920", "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_heading": "Relaciones",
"person_relationships_empty": "Aún no se conocen relaciones.", "person_relationships_empty": "Aún no se conocen relaciones.",
"timeline_aria_label": "Cronología de densidad de documentos", "timeline_aria_label": "Cronología de densidad de documentos",

View File

@@ -100,6 +100,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/geschichten/{id}/items/reorder": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1640,22 +1656,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/api/persons/{id}/aliases/{aliasId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1853,6 +1853,50 @@ export interface components {
provisional: boolean; provisional: boolean;
readonly displayName: string; 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: { JourneyReorderDTO: {
itemIds?: string[]; itemIds?: string[];
}; };
@@ -2008,42 +2052,6 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
targetId: string; 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: { PersonNameAliasDTO: {
lastName: string; lastName: string;
firstName?: 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: { reorderItems: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3663,7 +3719,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CreateRelationshipRequest"]; "application/json": components["schemas"]["RelationshipUpsertRequest"];
}; };
}; };
responses: { 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: { removeAlias: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -193,7 +193,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-spouse', relatedPersonId: 'p-spouse',
personDisplayName: 'Auguste', personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Otto Raddatz', relatedPersonDisplayName: 'Otto Raddatz',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'r2', id: 'r2',
@@ -201,7 +203,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-friend', relatedPersonId: 'p-friend',
personDisplayName: 'Auguste', personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend', relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND' relationType: 'FRIEND',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'r3', id: 'r3',
@@ -209,7 +213,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-sibling', relatedPersonId: 'p-sibling',
personDisplayName: 'Auguste', personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Marie Sister', relatedPersonDisplayName: 'Marie Sister',
relationType: 'SIBLING_OF' relationType: 'SIBLING_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
]; ];
render(PersonHoverCard, { render(PersonHoverCard, {
@@ -235,7 +241,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-aug', relatedPersonId: 'p-aug',
personDisplayName: 'Heinrich Raddatz', personDisplayName: 'Heinrich Raddatz',
relatedPersonDisplayName: 'Auguste Raddatz', relatedPersonDisplayName: 'Auguste Raddatz',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
]; ];
render(PersonHoverCard, { render(PersonHoverCard, {
@@ -258,7 +266,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-friend', relatedPersonId: 'p-friend',
personDisplayName: 'Auguste', personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend', relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND' relationType: 'FRIEND',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
]; ];
render(PersonHoverCard, { render(PersonHoverCard, {

View File

@@ -1,17 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.svelte'; import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate'; import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no // Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773). // sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
const PERSON_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.person_precision_day },
{ value: 'MONTH', label: m.person_precision_month },
{ value: 'YEAR', label: m.person_precision_year }
];
let { let {
name, name,
legend, legend,
@@ -26,73 +19,21 @@ let {
initialPrecision?: string | null; initialPrecision?: string | null;
} = $props(); } = $props();
let iso = $state(''); const precisions: { value: DatePrecision; label: string }[] = $derived([
let errorMessage = $state<string | null>(null); { value: 'DAY', label: m.person_precision_day() },
let inputEl = $state<HTMLInputElement | undefined>(); { value: 'MONTH', label: m.person_precision_month() },
let precision = $state<DatePrecision>('DAY'); { value: 'YEAR', label: m.person_precision_year() }
]);
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not const hint = $derived(`${m.person_precision_hint()} · ${m.person_date_placeholder_hint()}`);
// stomp the user's in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// Legacy APPROX/SEASON/RANGE precision is not editable here — seed YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — submitting then would silently
// clear a stored date. Block native submission until completed or fully emptied.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
const controlCls =
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
</script> </script>
<fieldset> <DateInputWithPrecision
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"> name={name}
{legend} legend={legend}
</legend> precisionLabel={precisionLabel}
<div class="flex flex-col gap-2 sm:flex-row"> precisions={precisions}
<div class="flex-1"> hint={hint}
<DateInput initialIso={initialIso}
bind:value={iso} initialPrecision={initialPrecision}
bind:errorMessage={errorMessage} selectClass="bg-surface"
bind:inputEl={inputEl} />
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class={controlCls}
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {precisionLabel}"
bind:value={precision}
class="{controlCls} bg-surface"
>
{#each PERSON_DATE_PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">
{m.person_precision_hint()} · {m.person_date_placeholder_hint()}
</p>
</fieldset>

View File

@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte'; import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte'; import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels'; import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -29,13 +30,15 @@ let {
type RelationType = NonNullable<RelationshipDTO['relationType']>; type RelationType = NonNullable<RelationshipDTO['relationType']>;
const sortedDirect = $derived([...relationships].sort(byTypeThenYear)); const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
const topDerived = $derived(inferredRelationships.slice(0, 5)); const topDerived = $derived(inferredRelationships.slice(0, 5));
let editingRelId = $state<string | null>(null);
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number { function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number {
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType); const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
if (order !== 0) return order; 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 { function relationTypeOrder(t: RelationType | undefined): number {
@@ -53,13 +56,13 @@ function relationTypeOrder(t: RelationType | undefined): number {
return order[t ?? 'OTHER'] ?? 99; return order[t ?? 'OTHER'] ?? 99;
} }
function yearRange(rel: RelationshipDTO): string { function dateRangeOf(rel: RelationshipDTO): string {
const from = rel.fromYear; return formatRelationshipDateRange(
const to = rel.toYear; rel.fromDate,
if (from && to) return `${from}${to}`; rel.fromDatePrecision,
if (from) return m.relation_year_from({ year: from }); rel.toDate,
if (to) return m.relation_year_to({ year: to }); rel.toDatePrecision
return ''; );
} }
</script> </script>
@@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string {
<RelationshipChip <RelationshipChip
chipLabel={chipLabel(rel, personId)} chipLabel={chipLabel(rel, personId)}
otherName={otherName(rel, personId)} otherName={otherName(rel, personId)}
yearRange={yearRange(rel)} dateRange={dateRangeOf(rel)}
canWrite={canWrite} canWrite={canWrite}
relId={rel.id} relId={rel.id}
onEdit={canWrite ? () => (editingRelId = rel.id) : undefined}
/> />
{#if editingRelId === rel.id}
<li>
<AddRelationshipForm
personId={personId}
relationship={rel}
onClose={() => (editingRelId = null)}
/>
</li>
{/if}
{/each} {/each}
</ul> </ul>
{/if} {/if}

View File

@@ -111,17 +111,21 @@ describe('StammbaumCard', () => {
expect(items.length).toBeGreaterThanOrEqual(2); expect(items.length).toBeGreaterThanOrEqual(2);
}); });
it('renders the year range "fromto" for a relationship with both years', async () => { it('renders the date range "from to" for a relationship with both dates', async () => {
render(StammbaumCard, { render(StammbaumCard, {
props: baseProps({ props: baseProps({
relationships: [ relationships: [
{ {
id: 'r-1', id: 'r-1',
personId: 'p-1',
relatedPersonId: 'p-x',
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Xavier',
relationType: 'COLLEAGUE', relationType: 'COLLEAGUE',
fromYear: 1940, fromDate: '1940-01-01',
toYear: 1945, fromDatePrecision: 'YEAR',
personA: { id: 'p-1', displayName: 'Anna' }, toDate: '1945-01-01',
personB: { id: 'p-x', displayName: 'Xavier' } toDatePrecision: 'YEAR'
} }
] ]
}) })
@@ -131,23 +135,27 @@ describe('StammbaumCard', () => {
expect(document.body.textContent).toContain('1945'); 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, { render(StammbaumCard, {
props: baseProps({ props: baseProps({
relationships: [ relationships: [
{ {
id: 'r-2', id: 'r-2',
personId: 'p-1',
relatedPersonId: 'p-y',
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Yvonne',
relationType: 'NEIGHBOR', relationType: 'NEIGHBOR',
fromYear: 1935, fromDate: '1935-01-01',
personA: { id: 'p-1', displayName: 'Anna' }, fromDatePrecision: 'YEAR',
personB: { id: 'p-y', displayName: 'Yvonne' } toDatePrecision: 'UNKNOWN'
} }
] ]
}) })
}); });
expect(document.body.textContent).toContain('1935'); 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 () => { it('renders the inferred-relationships disclosure when topDerived has items', async () => {

View File

@@ -250,7 +250,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={bCenter.y} y2={bCenter.y}
stroke="var(--c-primary)" stroke="var(--c-primary)"
stroke-width="1.5" stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined} stroke-dasharray={e.toDate ? '4 4' : undefined}
/> />
<circle <circle
cx={(aCenter.x + bCenter.x) / 2} cx={(aCenter.x + bCenter.x) / 2}

View File

@@ -18,7 +18,9 @@ function parentEdge(parentId: string, childId: string): RelationshipDTO {
relatedPersonId: childId, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -30,7 +32,9 @@ function endedSpouseEdge(a: string, b: string): RelationshipDTO {
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF', relationType: 'SPOUSE_OF',
toYear: 1950 fromDatePrecision: 'UNKNOWN',
toDate: '1950-01-01',
toDatePrecision: 'YEAR'
}; };
} }

View File

@@ -54,12 +54,19 @@ async function loadFor(id: string) {
} }
async function handleAddRelationship(data: RelFormData) { async function handleAddRelationship(data: RelFormData) {
const body: Record<string, string | number> = { const body: Record<string, string> = {
relatedPersonId: data.relatedPersonId, relatedPersonId: data.relatedPersonId,
relationType: data.relationType relationType: data.relationType
}; };
if (data.fromYear !== undefined) body.fromYear = data.fromYear; if (data.fromDate) {
if (data.toYear !== undefined) body.toYear = data.toYear; 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`, { const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -72,20 +72,20 @@ describe('StammbaumSidePanel', () => {
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); 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 }); render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) => const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
/Beziehung hinzufügen/i.test(b.textContent ?? '') /Beziehung hinzufügen/i.test(b.textContent ?? '')
); );
addBtn!.click(); addBtn!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const yearInputs = [...document.querySelectorAll('input')].filter( const dateInputs = [...document.querySelectorAll('input')].filter(
(i) => i.inputMode === 'numeric' (i) => i.inputMode === 'numeric'
); );
expect(yearInputs.length).toBeGreaterThan(0); expect(dateInputs.length).toBeGreaterThan(0);
for (const input of yearInputs) { for (const input of dateInputs) {
expect(input.closest('label')).not.toBeNull(); expect(input.getAttribute('aria-label')).toBeTruthy();
} }
}); });

View File

@@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte'; import StammbaumTree from './StammbaumTree.svelte';
import type { PanZoomState } from './panZoom'; import type { PanZoomState } from './panZoom';
import { DIMMED_OPACITY } from './layout/highlightLineage'; 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_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002'; const ID_B = '00000000-0000-0000-0000-000000000002';
@@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: PARENT_B, relatedPersonId: PARENT_B,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1a', id: 'p1a',
@@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_1, relatedPersonId: CHILD_1,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1b', id: 'p1b',
@@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_1, relatedPersonId: CHILD_1,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2a', id: 'p2a',
@@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_2, relatedPersonId: CHILD_2,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2b', id: 'p2b',
@@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_2, relatedPersonId: CHILD_2,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: PARENT_B, relatedPersonId: PARENT_B,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: EUGENIE, relatedPersonId: EUGENIE,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p3', id: 'p3',
@@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p4', id: 'p4',
@@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 's2', id: 's2',
@@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HILDE, relatedPersonId: HILDE,
personDisplayName: 'Hans', personDisplayName: 'Hans',
relatedPersonDisplayName: 'Hilde', relatedPersonDisplayName: 'Hilde',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p5', id: 'p5',
@@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: LILI, relatedPersonId: LILI,
personDisplayName: 'Hans', personDisplayName: 'Hans',
relatedPersonDisplayName: 'Lili', relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p6', id: 'p6',
@@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: LILI, relatedPersonId: LILI,
personDisplayName: 'Hilde', personDisplayName: 'Hilde',
relatedPersonDisplayName: 'Lili', relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF', relationType: 'SPOUSE_OF',
toYear: 1925 fromDatePrecision: 'UNKNOWN',
toDate: '1925-01-01',
toDatePrecision: 'YEAR'
} }
], ],
selectedId: null, selectedId: null,
@@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Parent', personDisplayName: 'Parent',
relatedPersonDisplayName: 'Child', relatedPersonDisplayName: 'Child',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => {
personDisplayName: string; personDisplayName: string;
relatedPersonDisplayName: string; relatedPersonDisplayName: string;
relationType: 'PARENT_OF' | 'SPOUSE_OF'; relationType: 'PARENT_OF' | 'SPOUSE_OF';
fromDatePrecision: 'UNKNOWN';
toDatePrecision: 'UNKNOWN';
}; };
const edge = ( const edge = (
personId: string, personId: string,
@@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => {
relatedPersonId, relatedPersonId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType relationType,
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}); });
const NODES = [ const NODES = [
@@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
// year, then a deterministic id tie-break), not alphabetically — with no birth // 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 // 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. // deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
const FAMILY_EDGES = [ const FAMILY_EDGES: RelationshipDTO[] = [
{ {
id: 'sp', id: 'sp',
personId: WALTER, personId: WALTER,
relatedPersonId: EUGENIE, relatedPersonId: EUGENIE,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p3', id: 'p3',
@@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p4', id: 'p4',
@@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
]; ];

View File

@@ -42,7 +42,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
relatedPersonId: childId, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', 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, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
fromYear: number | undefined, fromYear: number | undefined,
id = a + b id = a + b
): RelationshipDTO { ): 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', () => { 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 // fail fast instead so the maintainer either updates the test or
// splits into a year-branch / name-branch pair. // splits into a year-branch / name-branch pair.
const spouseEdgesWithYear = fixtureEdges.filter( const spouseEdgesWithYear = fixtureEdges.filter(
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null (e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null
); );
expect( expect(
spouseEdgesWithYear, spouseEdgesWithYear,

View File

@@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO {
relatedPersonId: c, relatedPersonId: c,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', 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: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF', relationType: 'SPOUSE_OF',
...(fromYear != null ? { fromYear } : {}) fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
}; };
} }

View File

@@ -82,7 +82,10 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO
} else if (e.relationType === 'SPOUSE_OF') { } else if (e.relationType === 'SPOUSE_OF') {
addToSet(spouses, e.personId, e.relatedPersonId); addToSet(spouses, e.personId, e.relatedPersonId);
addToSet(spouses, e.relatedPersonId, e.personId); 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
);
} }
} }

View File

@@ -13,7 +13,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
relatedPersonId: childId, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', 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, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', 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, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SIBLING_OF' relationType: 'SIBLING_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }

View File

@@ -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, * Formats one life date (birth or death) at the precision the data claims.
* delegating all rendering to {@link formatDocumentDate}. Returns '' for a * Thin domain alias over the shared {@link formatDatePart}: carries no * / †
* missing date. Carries no * / † glyph — components that need the glyphs wrap * glyph — components that need the glyphs wrap them in their own `aria-hidden`
* them in their own `aria-hidden` markup so screen readers only hear the date. * markup so screen readers only hear the date.
*
* A missing precision falls back to YEAR: pre-V76 rows only knew a year, and
* a bare year is the only safe rendering for a date without precision metadata.
*/ */
export function formatLifeDate( export function formatLifeDate(
date: string | null | undefined, date: string | null | undefined,
precision: DatePrecision | null | undefined, precision: DatePrecision | null | undefined,
locale?: string locale?: string
): string { ): string {
if (!date) { return formatDatePart(date, precision, locale);
return '';
}
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
} }
/** /**

View File

@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import RelationshipDateField from '$lib/person/relationship/RelationshipDateField.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>; type RelationType = NonNullable<RelationshipDTO['relationType']>;
@@ -10,71 +13,96 @@ type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = { export type RelFormData = {
relatedPersonId: string; relatedPersonId: string;
relationType: RelationType; relationType: RelationType;
fromYear?: number; fromDate?: string;
toYear?: number; fromDatePrecision?: DatePrecision;
toDate?: string;
toDatePrecision?: DatePrecision;
notes?: string;
}; };
interface Props { interface Props {
personId: string; personId: string;
// When present the form is an EDIT: pre-filled and posting to ?/updateRelationship.
relationship?: RelationshipDTO;
onSubmit?: (data: RelFormData) => Promise<void>; onSubmit?: (data: RelFormData) => Promise<void>;
onClose?: () => void;
} }
let { personId, onSubmit }: Props = $props(); let { personId, relationship, onSubmit, onClose }: Props = $props();
const isEdit = $derived(relationship != null);
let open = $state(false); let open = $state(false);
let addType = $state<RelationType>('PARENT_OF'); let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state(''); let addRelatedPersonId = $state('');
let addRelatedPersonName = $state(''); let addRelatedPersonName = $state('');
let addFromYear = $state(''); let notes = $state('');
let addToYear = $state('');
let callbackError = $state<string | null>(null); let callbackError = $state<string | null>(null);
let submitting = $state(false);
const yearError = $derived.by(() => { // Seed once at mount (reading props in a closure avoids state_referenced_locally).
const from = addFromYear.trim(); // The parent re-creates this form per edited row, so the relationship never
const to = addToYear.trim(); // changes under a live instance.
if (!from || !to) return null; onMount(() => {
const fromInt = parseInt(from, 10); if (!relationship) return;
const toInt = parseInt(to, 10); open = true;
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null; addType = relationship.relationType ?? 'PARENT_OF';
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null; const viewpointIsSubject = relationship.personId === personId;
addRelatedPersonId =
(viewpointIsSubject ? relationship.relatedPersonId : relationship.personId) ?? '';
addRelatedPersonName =
(viewpointIsSubject ? relationship.relatedPersonDisplayName : relationship.personDisplayName) ??
'';
notes = relationship.notes ?? '';
}); });
const selfError = $derived( const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
); );
const submitDisabled = $derived( const submitDisabled = $derived(selfError !== null || addRelatedPersonId === '');
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
function reset() { function reset() {
addType = 'PARENT_OF'; addType = 'PARENT_OF';
addRelatedPersonId = ''; addRelatedPersonId = '';
addRelatedPersonName = ''; addRelatedPersonName = '';
addFromYear = ''; notes = '';
addToYear = '';
callbackError = null; callbackError = null;
} }
function cancel() { function cancel() {
if (isEdit) {
onClose?.();
return;
}
open = false; open = false;
reset(); reset();
} }
async function handleCallbackSubmit(event: Event) { async function handleCallbackSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (submitDisabled || !onSubmit) return; if (submitDisabled || !onSubmit) return;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType }; const fd = new FormData(event.currentTarget as HTMLFormElement);
const from = parseInt(addFromYear.trim(), 10); const fromDate = (fd.get('fromDate') as string) || undefined;
if (!Number.isNaN(from)) data.fromYear = from; const toDate = (fd.get('toDate') as string) || undefined;
const to = parseInt(addToYear.trim(), 10); const data: RelFormData = {
if (!Number.isNaN(to)) data.toYear = to; relatedPersonId: addRelatedPersonId,
relationType: addType,
fromDate,
fromDatePrecision: fromDate ? (fd.get('fromDatePrecision') as DatePrecision) : undefined,
toDate,
toDatePrecision: toDate ? (fd.get('toDatePrecision') as DatePrecision) : undefined,
notes: (fd.get('notes') as string)?.trim() || undefined
};
submitting = true;
try { try {
await onSubmit(data); await onSubmit(data);
open = false; open = false;
reset(); reset();
} catch { } catch {
callbackError = m.error_internal_error(); callbackError = m.error_internal_error();
} finally {
submitting = false;
} }
} }
</script> </script>
@@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) {
compact compact
/> />
</div> </div>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2"
>{m.relation_form_field_from_year()}</span
>
<input
type="text"
name="fromYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addFromYear}
placeholder={m.relation_form_year_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
>
<input
type="text"
name="toYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addToYear}
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
{#if yearError}
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
{yearError}
</p>
{/if}
</label>
</div> </div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<RelationshipDateField
name="fromDate"
legend={m.relation_label_from_date()}
initialIso={relationship?.fromDate ?? ''}
initialPrecision={relationship?.fromDatePrecision ?? null}
/>
<RelationshipDateField
name="toDate"
legend={m.relation_label_to_date()}
initialIso={relationship?.toDate ?? ''}
initialPrecision={relationship?.toDatePrecision ?? null}
/>
</div>
<label class="mt-3 block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_notes()}</span>
<textarea
name="notes"
maxlength="2000"
rows="2"
bind:value={notes}
placeholder={m.relation_notes_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 font-serif text-sm text-ink-3 focus:border-primary focus:outline-none"
></textarea>
</label>
{#if selfError} {#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p> <p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if} {/if}
@@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) {
</button> </button>
<button <button
type="submit" type="submit"
disabled={submitDisabled} disabled={submitDisabled || submitting}
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40" aria-busy={submitting}
class="inline-flex items-center gap-1.5 rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
> >
{m.relation_btn_add()} {#if submitting}
<span
class="h-3 w-3 animate-spin rounded-full border-2 border-primary-fg/40 border-t-primary-fg"
data-testid="submit-spinner"
aria-hidden="true"
></span>
{/if}
{isEdit ? m.relation_btn_save() : m.relation_btn_add()}
</button> </button>
</div> </div>
{/snippet} {/snippet}
@@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) {
{:else} {:else}
<form <form
method="POST" method="POST"
action="?/addRelationship" action={isEdit ? '?/updateRelationship' : '?/addRelationship'}
use:enhance={() => { use:enhance={() => {
submitting = true;
return async ({ result, update }) => { return async ({ result, update }) => {
await update(); await update();
submitting = false;
if (result.type === 'success') { if (result.type === 'success') {
open = false; if (isEdit) {
reset(); onClose?.();
} else {
open = false;
reset();
}
} }
}; };
}} }}
class="mt-3 rounded-sm border border-line bg-muted/40 p-3" class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
> >
{#if relationship}
<input type="hidden" name="relId" value={relationship.id} />
{/if}
{@render formFields()} {@render formFields()}
</form> </form>
{/if} {/if}

View File

@@ -8,58 +8,116 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
afterEach(cleanup); afterEach(cleanup);
describe('AddRelationshipForm', () => { const PID = 'person-1';
it('shows add-relationship button initially and no form', async () => { const OTHER = 'person-2';
render(AddRelationshipForm, { personId: 'person-1' });
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('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 () => { it('shows the relationType select when the add toggle is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('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 () => { it('hides the form and shows the toggle again on cancel', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find( const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '') (b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
); );
cancelBtn!.click(); 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 () => { it('disables submit when no person is selected', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled(); 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); const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { personId: 'person-1', onSubmit }); render(AddRelationshipForm, { personId: PID, onSubmit });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const form = document.querySelector('form'); expect(document.querySelector('form')?.hasAttribute('action')).toBe(false);
expect(form?.hasAttribute('action')).toBe(false); });
}); });
it('shows year-range error when toYear is before fromYear', async () => { describe('AddRelationshipForm — edit mode', () => {
render(AddRelationshipForm, { personId: 'person-1' }); it('opens pre-filled and labels the submit "Speichern"', async () => {
document.querySelector<HTMLButtonElement>('button')!.click(); render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument();
});
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935'; it('pre-fills the from-date as dd.mm.yyyy', async () => {
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true })); render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!; const fromInput = document.querySelector<HTMLInputElement>('#fromDate')!;
toInput.value = '1920'; await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923'));
toInput.dispatchEvent(new InputEvent('input', { bubbles: true })); });
await expect.element(page.getByRole('alert')).toBeVisible(); 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<HTMLTextAreaElement>('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<HTMLOptionElement>('#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<void>((r) => (resolve = r)));
render(AddRelationshipForm, { personId: PID, relationship: editRel(), onSubmit });
const submit = await vi.waitFor(() => {
const b = [...document.querySelectorAll<HTMLButtonElement>('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();
}); });
}); });

View File

@@ -33,36 +33,6 @@ describe('AddRelationshipForm', () => {
expect(optionValues).toContain('OTHER'); 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 () => { it('cancel button closes the form', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } }); render(AddRelationshipForm, { props: { personId: 'p-1' } });
@@ -136,25 +106,4 @@ describe('AddRelationshipForm', () => {
expect(submitBtn!.disabled).toBe(true); 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);
});
});
}); });

View File

@@ -5,12 +5,13 @@ import { m } from '$lib/paraglide/messages.js';
interface Props { interface Props {
chipLabel: string; chipLabel: string;
otherName: string; otherName: string;
yearRange?: string; dateRange?: string;
canWrite: boolean; canWrite: boolean;
relId: string; relId: string;
onEdit?: () => void;
} }
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props(); let { chipLabel, otherName, dateRange = '', canWrite, relId, onEdit }: Props = $props();
</script> </script>
<li class="flex items-center gap-2 py-2"> <li class="flex items-center gap-2 py-2">
@@ -22,8 +23,31 @@ let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink"> <span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{otherName} {otherName}
</span> </span>
{#if yearRange} {#if dateRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span> <span class="shrink-0 font-sans text-xs text-ink-3" data-testid="date-range">{dateRange}</span>
{/if}
{#if canWrite && onEdit}
<button
type="button"
onclick={onEdit}
aria-label="{m.relation_edit()} {otherName}"
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-primary"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
/>
</svg>
</button>
{/if} {/if}
{#if canWrite} {#if canWrite}
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0"> <form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">

View File

@@ -10,7 +10,7 @@ afterEach(cleanup);
const baseProps = { const baseProps = {
chipLabel: 'Elternteil', chipLabel: 'Elternteil',
otherName: 'Anna Schmidt', otherName: 'Anna Schmidt',
yearRange: '', dateRange: '',
canWrite: false, canWrite: false,
relId: 'rel-1' relId: 'rel-1'
}; };
@@ -26,30 +26,55 @@ describe('RelationshipChip', () => {
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
}); });
it('shows year range when provided', async () => { it('shows the date range when provided', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '19201980' }); render(RelationshipChip, { ...baseProps, dateRange: '12. Mai 1923 1958' });
await expect.element(page.getByText('19201980')).toBeInTheDocument(); await expect.element(page.getByText('12. Mai 1923 1958')).toBeInTheDocument();
}); });
it('does not show year range span when empty', async () => { it('does not render a date-range span when empty', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '' }); render(RelationshipChip, { ...baseProps, dateRange: '' });
expect(document.querySelector('[data-testid="year-range"]')).toBeNull(); 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 }); render(RelationshipChip, { ...baseProps, canWrite: true });
await expect.element(page.getByRole('button')).toBeInTheDocument(); 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 }); render(RelationshipChip, { ...baseProps, canWrite: false });
expect(document.querySelector('button')).toBeNull(); 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 }); render(RelationshipChip, { ...baseProps, canWrite: true });
const btn = document.querySelector('button')!; const btn = document.querySelector('button')!;
expect(btn.className).toContain('h-11'); expect(btn.className).toContain('h-11');
expect(btn.className).toContain('w-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<HTMLButtonElement>('button[aria-label*="bearbeiten"]')!;
editBtn.click();
expect(onEdit).toHaveBeenCalledOnce();
});
}); });

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered (same as the person life-date field and the
// 60+ author audience). Storage still accepts all seven precisions; SEASON/RANGE/
// APPROX render correctly elsewhere but make no sense to enter for a relationship.
let {
name,
legend,
initialIso = '',
initialPrecision = null
}: {
name: string;
legend: string;
initialIso?: string | null;
initialPrecision?: string | null;
} = $props();
const precisions: { value: DatePrecision; label: string }[] = $derived([
{ value: 'DAY', label: m.relation_precision_day() },
{ value: 'MONTH', label: m.relation_precision_month() },
{ value: 'YEAR', label: m.relation_precision_year() }
]);
</script>
<DateInputWithPrecision
name={name}
legend={legend}
precisionLabel={m.relation_label_date_precision()}
precisions={precisions}
hint={m.relation_date_placeholder_hint()}
initialIso={initialIso}
initialPrecision={initialPrecision}
inputClass="bg-surface"
selectClass="bg-surface text-ink-3"
/>

View File

@@ -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');
});
});
});

View File

@@ -0,0 +1,30 @@
import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Formats a relationship's startend 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 '';
}

View File

@@ -19,6 +19,8 @@ function makeRel(
personDisplayName: 'Alice', personDisplayName: 'Alice',
relatedPersonDisplayName: 'Bob', relatedPersonDisplayName: 'Bob',
relationType, relationType,
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
...override ...override
}; };
} }

View File

@@ -10,6 +10,7 @@ export type ErrorCode =
| 'INVALID_PERSON_TYPE' | 'INVALID_PERSON_TYPE'
| 'BIRTH_AFTER_DEATH' | 'BIRTH_AFTER_DEATH'
| 'INVALID_DATE_PRECISION' | 'INVALID_DATE_PRECISION'
| 'INVALID_RELATIONSHIP_DATES'
| 'INVALID_DATE_RANGE' | 'INVALID_DATE_RANGE'
| 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE' | 'DOCUMENT_NO_FILE'
@@ -106,6 +107,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_birth_after_death(); return m.error_birth_after_death();
case 'INVALID_DATE_PRECISION': case 'INVALID_DATE_PRECISION':
return m.error_invalid_date_precision(); return m.error_invalid_date_precision();
case 'INVALID_RELATIONSHIP_DATES':
return m.error_invalid_relationship_dates();
case 'INVALID_DATE_RANGE': case 'INVALID_DATE_RANGE':
return m.error_invalid_date_range(); return m.error_invalid_date_range();
case 'DOCUMENT_NOT_FOUND': case 'DOCUMENT_NOT_FOUND':

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Compact date + precision field: the {@link DateInput} primitive paired with a
* precision <select> offering a caller-chosen subset of precisions. Shared base of
* PersonLifeDateField (birth/death) and RelationshipDateField (from/to).
*
* Distinct from {@link DatePrecisionField} — that one is the full document/timeline
* field (all seven precisions, German free-text entry, RANGE end-date disclosure).
* This one is the restricted, single-input variant for the person-family forms.
*
* All copy (legend, precision labels, hint, the select's accessible name) and the
* offered precisions are injected by the caller so this stays domain-agnostic.
*/
let {
name,
legend,
precisionLabel,
hint,
precisions,
initialIso = '',
initialPrecision = null,
inputClass = '',
selectClass = ''
}: {
name: string;
legend: string;
precisionLabel: string;
hint: string;
precisions: { value: DatePrecision; label: string }[];
initialIso?: string | null;
initialPrecision?: string | null;
inputClass?: string;
selectClass?: string;
} = $props();
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount so a later load() rerun does not stomp an in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = precisions.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// A stored non-offered precision (SEASON/RANGE/APPROX) seeds as YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — block native submission until the
// date is completed or fully emptied, so a save can never silently clear a date.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
const controlCls =
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
</script>
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class="{controlCls} {inputClass}"
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {precisionLabel}"
bind:value={precision}
class="{controlCls} {selectClass}"
>
{#each precisions as p (p.value)}
<option value={p.value}>{p.label}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">{hint}</p>
</fieldset>

View File

@@ -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 ────────────────────────────────────────────────────── // ─── precision branches ──────────────────────────────────────────────────────
function longDate(iso: string, locale: string): string { function longDate(iso: string, locale: string): string {

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels'; import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -35,18 +36,37 @@ function otherId(rel: RelationshipDTO): string {
{#if relationships.length > 0} {#if relationships.length > 0}
<ul class="mb-4 space-y-2"> <ul class="mb-4 space-y-2">
{#each relationships as rel (rel.id)} {#each relationships as rel (rel.id)}
<li class="flex items-center gap-2"> {@const dateRange = formatRelationshipDateRange(
<span rel.fromDate,
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase" rel.fromDatePrecision,
> rel.toDate,
{chipLabel(rel, personId)} rel.toDatePrecision
</span> )}
<a <li class="flex flex-col gap-0.5">
href="/persons/{otherId(rel)}" <div class="flex items-center gap-2">
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline" <span
> class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
{otherName(rel, personId)} >
</a> {chipLabel(rel, personId)}
</span>
<a
href="/persons/{otherId(rel)}"
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline"
>
{otherName(rel, personId)}
</a>
{#if dateRange}
<span
class="shrink-0 font-sans text-xs text-ink-3"
data-testid="relationship-date-range">{dateRange}</span
>
{/if}
</div>
{#if rel.notes}
<p class="pl-1 font-serif text-xs text-ink-2 italic" data-testid="relationship-notes">
{rel.notes}
</p>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -18,7 +18,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: SPOUSE_ID, relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller', personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller', relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
inferredRelationships: [ inferredRelationships: [
@@ -65,7 +67,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: PARENT_ID, relatedPersonId: PARENT_ID,
personDisplayName: 'Anna Müller', personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Kind Müller', relatedPersonDisplayName: 'Kind Müller',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
inferredRelationships: [] inferredRelationships: []
@@ -84,7 +88,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: SPOUSE_ID, relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
inferredRelationships: [ inferredRelationships: [
@@ -113,7 +119,9 @@ describe('PersonRelationshipsCard', () => {
relatedPersonId: PERSON_ID, relatedPersonId: PERSON_ID,
personDisplayName: 'Eltern Müller', personDisplayName: 'Eltern Müller',
relatedPersonDisplayName: 'Anna Müller', relatedPersonDisplayName: 'Anna Müller',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
inferredRelationships: [] inferredRelationships: []
@@ -121,4 +129,74 @@ describe('PersonRelationshipsCard', () => {
await expect.element(page.getByText('Kind von')).toBeInTheDocument(); await expect.element(page.getByText('Kind von')).toBeInTheDocument();
}); });
it('renders the date range at its stored precision', async () => {
render(PersonRelationshipsCard, {
personId: PERSON_ID,
relationships: [
{
id: 'r1',
personId: PERSON_ID,
relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF',
fromDate: '1923-05-12',
fromDatePrecision: 'DAY',
toDatePrecision: 'UNKNOWN'
}
],
inferredRelationships: []
});
await expect
.element(page.getByTestId('relationship-date-range'))
.toHaveTextContent('12. Mai 1923');
});
it('shows the notes line', async () => {
render(PersonRelationshipsCard, {
personId: PERSON_ID,
relationships: [
{
id: 'r1',
personId: PERSON_ID,
relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
notes: 'Hochzeit in Berlin'
}
],
inferredRelationships: []
});
await expect
.element(page.getByTestId('relationship-notes'))
.toHaveTextContent('Hochzeit in Berlin');
});
it('renders no date line when the relationship has no dates', async () => {
render(PersonRelationshipsCard, {
personId: PERSON_ID,
relationships: [
{
id: 'r1',
personId: PERSON_ID,
relatedPersonId: SPOUSE_ID,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Bertha Müller',
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
],
inferredRelationships: []
});
await expect.element(page.getByText('Bertha Müller')).toBeInTheDocument();
expect(document.querySelector('[data-testid="relationship-date-range"]')).toBeNull();
});
}); });

View File

@@ -2,12 +2,40 @@ import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
import type { DatePrecision } from '$lib/shared/utils/documentDate'; import type { DatePrecision } from '$lib/shared/utils/documentDate';
import type { components } from '$lib/generated/api';
import { import {
normalizePersonType, normalizePersonType,
validatePersonFields, validatePersonFields,
resolveValidationMessage resolveValidationMessage
} from '$lib/person/person-validation'; } from '$lib/person/person-validation';
type RelationType = NonNullable<components['schemas']['RelationshipUpsertRequest']['relationType']>;
// Parses the shared relationship create/update form into a RelationshipUpsertRequest
// body. An empty date omits date AND precision so the backend normalises the pair to
// null/UNKNOWN — a lone precision would fail the coherence check (INVALID_DATE_PRECISION).
function parseRelationshipForm(formData: FormData) {
const relatedPersonId = formData.get('relatedPersonId')?.toString();
const relationType = formData.get('relationType')?.toString();
const notes = formData.get('notes')?.toString().trim() || undefined;
const fromDate = formData.get('fromDate')?.toString().trim() || undefined;
const fromDatePrecision = fromDate
? (formData.get('fromDatePrecision')?.toString() as DatePrecision)
: undefined;
const toDate = formData.get('toDate')?.toString().trim() || undefined;
const toDatePrecision = toDate
? (formData.get('toDatePrecision')?.toString() as DatePrecision)
: undefined;
const body = {
relatedPersonId: relatedPersonId ?? '',
relationType: (relationType ?? 'OTHER') as RelationType,
...(fromDate ? { fromDate, fromDatePrecision } : {}),
...(toDate ? { toDate, toDatePrecision } : {}),
...(notes ? { notes } : {})
};
return { relatedPersonId, relationType, body };
}
export async function load({ params, fetch, locals }) { export async function load({ params, fetch, locals }) {
const canWrite = const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) => (locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
@@ -193,40 +221,45 @@ export const actions = {
addRelationship: async ({ request, params, fetch }) => { addRelationship: async ({ request, params, fetch }) => {
const formData = await request.formData(); const formData = await request.formData();
const relatedPersonId = formData.get('relatedPersonId')?.toString(); const fields = parseRelationshipForm(formData);
const relationType = formData.get('relationType')?.toString();
const fromYearRaw = formData.get('fromYear')?.toString().trim();
const toYearRaw = formData.get('toYear')?.toString().trim();
const notes = formData.get('notes')?.toString().trim() || undefined;
if (!relatedPersonId || !relationType) { if (!fields.relatedPersonId || !fields.relationType) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
} }
if (relatedPersonId === params.id) { if (fields.relatedPersonId === params.id) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
const fromYear = fromYearRaw ? parseInt(fromYearRaw, 10) : undefined;
const toYear = toYearRaw ? parseInt(toYearRaw, 10) : undefined;
if (
fromYear !== undefined &&
toYear !== undefined &&
!Number.isNaN(fromYear) &&
!Number.isNaN(toYear) &&
toYear < fromYear
) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') }); return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
} }
const api = createApiClient(fetch); const api = createApiClient(fetch);
const result = await api.POST('/api/persons/{id}/relationships', { const result = await api.POST('/api/persons/{id}/relationships', {
params: { path: { id: params.id } }, params: { path: { id: params.id } },
body: { body: fields.body
relatedPersonId, });
relationType,
...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}), if (!result.response.ok) {
...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}), return fail(result.response.status, {
...(notes ? { notes } : {}) relationshipError: getErrorMessage(extractErrorCode(result.error))
} });
}
return { relationshipSuccess: true };
},
updateRelationship: async ({ request, params, fetch }) => {
const formData = await request.formData();
const relId = formData.get('relId')?.toString();
const fields = parseRelationshipForm(formData);
if (!relId || !fields.relatedPersonId || !fields.relationType) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
if (fields.relatedPersonId === params.id) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
const api = createApiClient(fetch);
const result = await api.PUT('/api/persons/{id}/relationships/{relId}', {
params: { path: { id: params.id, relId } },
body: fields.body
}); });
if (!result.response.ok) { if (!result.response.ok) {

View File

@@ -97,3 +97,98 @@ describe('persons/[id]/edit update action — generation (#689)', () => {
expect(body).toHaveProperty('generation', 3); expect(body).toHaveProperty('generation', 3);
}); });
}); });
describe('persons/[id]/edit relationship actions (#837)', () => {
function relForm(overrides: Record<string, string | null> = {}): Request {
const fd = new FormData();
fd.set('relatedPersonId', 'p2');
fd.set('relationType', 'SPOUSE_OF');
for (const [k, v] of Object.entries(overrides)) {
if (v == null) fd.delete(k);
else fd.set(k, v);
}
return new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd });
}
it('addRelationship posts date + precision + notes', async () => {
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({
fromDate: '1923-05-12',
fromDatePrecision: 'DAY',
notes: 'Hochzeit'
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const [path, opts] = post.mock.calls[0];
expect(path).toBe('/api/persons/{id}/relationships');
expect(opts.body).toMatchObject({
relatedPersonId: 'p2',
relationType: 'SPOUSE_OF',
fromDate: '1923-05-12',
fromDatePrecision: 'DAY',
notes: 'Hochzeit'
});
});
it('addRelationship omits precision when the date is empty (coherence)', async () => {
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ fromDatePrecision: 'DAY' }); // precision but no date
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const body = post.mock.calls[0][1].body;
expect(body).not.toHaveProperty('fromDate');
expect(body).not.toHaveProperty('fromDatePrecision');
});
it('updateRelationship PUTs to the relId path with the new body', async () => {
const put = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ relId: 'rel-9', fromDate: '1923-05-12', fromDatePrecision: 'DAY' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.updateRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const [path, opts] = put.mock.calls[0];
expect(path).toBe('/api/persons/{id}/relationships/{relId}');
expect(opts.params.path).toMatchObject({ id: 'p1', relId: 'rel-9' });
expect(opts.body).toMatchObject({
relatedPersonId: 'p2',
relationType: 'SPOUSE_OF',
fromDate: '1923-05-12',
fromDatePrecision: 'DAY'
});
});
it('updateRelationship surfaces a backend error as a fail', async () => {
const put = vi.fn().mockResolvedValue({
response: { ok: false, status: 400 },
error: { code: 'INVALID_RELATIONSHIP_DATES' }
});
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ relId: 'rel-9' });
const result = (await actions.updateRelationship({
request,
params: { id: 'p1' },
fetch: mockFetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)) as { status: number; data: { relationshipError: string } };
expect(result.status).toBe(400);
expect(result.data.relationshipError).toBeTruthy();
});
});