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
188 lines
5.6 KiB
Svelte
188 lines
5.6 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
|
|
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
|
|
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
|
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
|
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
|
|
|
|
interface Props {
|
|
personId: string;
|
|
familyMember: boolean;
|
|
relationships: RelationshipDTO[];
|
|
inferredRelationships: InferredRelationshipWithPersonDTO[];
|
|
canWrite: boolean;
|
|
relationshipError?: string | null;
|
|
}
|
|
|
|
let {
|
|
personId,
|
|
familyMember,
|
|
relationships,
|
|
inferredRelationships,
|
|
canWrite,
|
|
relationshipError = null
|
|
}: Props = $props();
|
|
|
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
|
|
|
const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
|
|
const topDerived = $derived(inferredRelationships.slice(0, 5));
|
|
let editingRelId = $state<string | null>(null);
|
|
|
|
function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number {
|
|
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
|
|
if (order !== 0) return order;
|
|
// ISO dates sort lexicographically == chronologically; a missing date sorts first.
|
|
return (a.fromDate ?? '').localeCompare(b.fromDate ?? '');
|
|
}
|
|
|
|
function relationTypeOrder(t: RelationType | undefined): number {
|
|
const order: Record<string, number> = {
|
|
PARENT_OF: 1,
|
|
SPOUSE_OF: 2,
|
|
SIBLING_OF: 3,
|
|
FRIEND: 4,
|
|
COLLEAGUE: 5,
|
|
EMPLOYER: 6,
|
|
DOCTOR: 7,
|
|
NEIGHBOR: 8,
|
|
OTHER: 9
|
|
};
|
|
return order[t ?? 'OTHER'] ?? 99;
|
|
}
|
|
|
|
function dateRangeOf(rel: RelationshipDTO): string {
|
|
return formatRelationshipDateRange(
|
|
rel.fromDate,
|
|
rel.fromDatePrecision,
|
|
rel.toDate,
|
|
rel.toDatePrecision
|
|
);
|
|
}
|
|
</script>
|
|
|
|
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
|
<!-- Header row: heading + family-member toggle -->
|
|
<div class="mb-5 flex items-start justify-between gap-4">
|
|
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
{m.stammbaum_relationships_heading()}
|
|
</h2>
|
|
{#if canWrite}
|
|
<form method="POST" action="?/toggleFamilyMember" use:enhance>
|
|
<input type="hidden" name="familyMember" value={familyMember ? 'false' : 'true'} />
|
|
<button
|
|
type="submit"
|
|
role="switch"
|
|
aria-checked={familyMember}
|
|
aria-label={familyMember
|
|
? m.relation_toggle_remove_from_tree()
|
|
: m.relation_toggle_add_to_tree()}
|
|
class="inline-flex items-center gap-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
|
>
|
|
<span
|
|
class="relative inline-block h-4 w-7 rounded-full transition-colors {familyMember
|
|
? 'bg-primary'
|
|
: 'bg-line'}"
|
|
>
|
|
<span
|
|
class="absolute top-0.5 left-0.5 inline-block h-3 w-3 rounded-full bg-white transition-transform {familyMember
|
|
? 'translate-x-3'
|
|
: ''}"
|
|
></span>
|
|
</span>
|
|
{m.relation_label_family_member()}
|
|
</button>
|
|
</form>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if relationshipError}
|
|
<p class="mb-3 text-sm text-red-700" role="alert">{relationshipError}</p>
|
|
{/if}
|
|
|
|
<!-- In-tree banner -->
|
|
{#if familyMember}
|
|
<div
|
|
class="mb-4 flex items-center justify-between rounded-sm border border-accent/30 bg-accent/10 px-3 py-2"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<span class="inline-block h-2 w-2 rounded-full bg-accent"></span>
|
|
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_in_tree()}</span>
|
|
</div>
|
|
<a
|
|
href="/stammbaum?focus={personId}"
|
|
class="font-sans text-xs font-medium text-primary hover:underline"
|
|
>
|
|
{m.relation_label_view_in_tree()}
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Direkte Beziehungen -->
|
|
<h3 class="mb-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
{m.relation_label_direct()}
|
|
</h3>
|
|
{#if sortedDirect.length === 0}
|
|
<p class="mb-2 text-sm text-ink-2 italic">{m.person_relationships_empty()}</p>
|
|
{:else}
|
|
<ul class="mb-2 divide-y divide-line">
|
|
{#each sortedDirect as rel (rel.id)}
|
|
<RelationshipChip
|
|
chipLabel={chipLabel(rel, personId)}
|
|
otherName={otherName(rel, personId)}
|
|
dateRange={dateRangeOf(rel)}
|
|
canWrite={canWrite}
|
|
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}
|
|
</ul>
|
|
{/if}
|
|
|
|
{#if canWrite}
|
|
<AddRelationshipForm personId={personId} />
|
|
{/if}
|
|
|
|
<!-- Abgeleitete Beziehungen -->
|
|
{#if topDerived.length > 0}
|
|
<details class="mt-6">
|
|
<summary
|
|
class="cursor-pointer text-xs font-bold tracking-widest text-ink-3 uppercase select-none"
|
|
>
|
|
{m.relation_label_derived()}
|
|
</summary>
|
|
<ul class="mt-2 space-y-2">
|
|
{#each topDerived as derived (derived.person.id)}
|
|
<li class="flex items-center gap-2">
|
|
<span
|
|
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
|
>
|
|
{inferredRelationshipLabel(derived.label)}
|
|
</span>
|
|
<a
|
|
href="/persons/{derived.person.id}"
|
|
class="min-w-0 flex-1 truncate font-serif text-sm text-ink-2 hover:underline"
|
|
>
|
|
{derived.person.displayName}
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</details>
|
|
{/if}
|
|
</div>
|