feat: Stammbaum — family relationship graph replaces Briefwechsel in nav #358

Closed
opened 2026-04-27 10:01:07 +02:00 by marcel · 10 comments
Owner

Spec status: Ready for implementation
Replaces: /briefwechsel nav item
Trigger: Users reading letters ask "how are these two related?" — the app has no way to express this. Briefwechsel duplicates home search and is unused.


Problem & Motivation

Three converging user signals:

  1. "How are these two related?" — Users reading a letter see sender and receiver and want to know their family relationship before reading (uncle writing to niece, son writing to mother). The app currently has no way to express this.
  2. Dead Briefwechsel page — The bilateral correspondence filter duplicates the home search. Users don't understand when to use it. The nav slot is wasted.
  3. Differentiation from paperlessNGX — The second unique feature after collaborative Kurrent transcription. A family-aware relationship graph with automatic inference is something a generic document archive cannot offer.

Feature Overview

Replace /briefwechsel in the nav with /stammbaum. Four interlocking parts:

Part Where What it does
A — Data model PostgreSQL family_member flag + person_relationships table
B — Stammbaum page /stammbaum SVG family tree (generational layout); toggle to social network view
C — Relationship badge Document detail page Shows computed relationship between sender and receiver
D — Relationship editor Person edit page Toggle family_member, add/remove direct relationships

Data Model

Migration: V54__add_family_network.sql

-- Mark persons as family members (shown in the Stammbaum)
ALTER TABLE persons
    ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;

-- All direct relationships between persons
CREATE TABLE person_relationships (
    id                UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    person_id         UUID        NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
    related_person_id UUID        NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
    relation_type     VARCHAR(30) NOT NULL,
    from_year         INTEGER,
    to_year           INTEGER,
    notes             TEXT,
    created_at        TIMESTAMP   NOT NULL DEFAULT NOW(),
    CONSTRAINT no_self_rel    CHECK (person_id != related_person_id),
    CONSTRAINT unique_rel     UNIQUE (person_id, related_person_id, relation_type)
);

CREATE INDEX idx_person_rel_person  ON person_relationships(person_id);
CREATE INDEX idx_person_rel_related ON person_relationships(related_person_id);

Relationship types

Only direct, first-degree relationships are stored. All others are derived at query time.

Family (stored):

Type Meaning Symmetric?
PARENT_OF person_id is parent of related_person_id No — inverse inferred as CHILD_OF
SPOUSE_OF Marriage or partnership Yes — one row per couple, queries check both directions
SIBLING_OF Fallback only when parents are not in the archive Yes

Social (stored):

Type Meaning
FRIEND Personal friendship
COLLEAGUE Work colleague
EMPLOYER person_id employed related_person_id
DOCTOR person_id was doctor of related_person_id
NEIGHBOR Neighbors
OTHER Free-form, use notes for details

Derived (never stored): grandparent, grandchild, uncle/aunt, niece/nephew, cousin, great-grandparent, in-laws — computed by RelationshipInferenceService.

Directionality rule: PARENT_OF always stored from the parent's perspective. The inverse label "CHILD_OF" is computed in the application. Symmetric types accept either direction; queries check both.


Backend

New entity: PersonRelationship

@Entity @Table(name = "person_relationships")
@Data @NoArgsConstructor @AllArgsConstructor @Builder
public class PersonRelationship {
    @Id @GeneratedValue(strategy = GenerationType.UUID)
    @Schema(requiredMode = REQUIRED)
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "person_id", nullable = false)
    private Person person;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "related_person_id", nullable = false)
    private Person relatedPerson;

    @Column(name = "relation_type", nullable = false)
    @Schema(requiredMode = REQUIRED)
    private String relationType;

    private Integer fromYear;
    private Integer toYear;
    private String notes;

    @CreationTimestamp
    @Schema(requiredMode = REQUIRED)
    private Instant createdAt;
}

New DTOs

// Request: add a relationship
public record CreateRelationshipRequest(
    UUID relatedPersonId,
    String relationType,      // validated against allowed types
    Integer fromYear,
    Integer toYear,
    String notes
) {}

// Response: a stored relationship edge
public record RelationshipDTO(
    UUID id,
    UUID personId,
    String personDisplayName,
    UUID relatedPersonId,
    String relatedPersonDisplayName,
    String relationType,
    Integer fromYear,
    Integer toYear,
    String notes
) {}

// Response: the full network graph
public record NetworkDTO(
    List<PersonSummaryDTO> nodes,   // reuses existing PersonSummaryDTO
    List<RelationshipDTO> edges
) {}

// Response: computed relationship between two persons
public record InferredRelationshipDTO(
    String labelFromA,    // e.g. "Onkel/Tante"
    String labelFromB,    // e.g. "Nichte/Neffe"
    List<String> path     // edge sequence, for tooltip
) {}

// Response: computed relationship paired with the related person
public record InferredRelationshipWithPersonDTO(
    UUID relatedPersonId,
    String relatedPersonDisplayName,
    String labelFromA,
    String labelFromB,
    List<String> path
) {}

API endpoints

GET  /api/network
     ?family=true               → only nodes where family_member = true
     → NetworkDTO               (used by Stammbaum page on load)

GET  /api/persons/{id}/relationships
     → List<RelationshipDTO>    (stored direct relationships only)

GET  /api/persons/{id}/inferred-relationships
     → List<InferredRelationshipWithPersonDTO>
     (computed by RelationshipInferenceService; used for read-only derived sections)

POST /api/persons/{id}/relationships
     body: CreateRelationshipRequest → RelationshipDTO
     @RequirePermission(WRITE_ALL)

DELETE /api/persons/{id}/relationships/{relId}
     @RequirePermission(WRITE_ALL)

PATCH /api/persons/{id}/family-member
     body: { "familyMember": true/false }
     @RequirePermission(WRITE_ALL)

GET  /api/persons/{aId}/relationship-to/{bId}
     → InferredRelationshipDTO | 404 if no path found
     (called by document detail page)

RelationshipInferenceService

BFS on an in-memory graph of all PARENT_OF, SPOUSE_OF, and SIBLING_OF edges among family members. Graph has at most ~30 nodes — single-millisecond traversal.

@Service
@RequiredArgsConstructor
public class RelationshipInferenceService {

    private final PersonRelationshipRepository relationshipRepository;

    public Optional<InferredRelationshipDTO> infer(UUID personAId, UUID personBId) {
        // 1. Load all family edges (PARENT_OF, SPOUSE_OF, SIBLING_OF)
        // 2. BFS from personAId, tracking the edge-type sequence
        // 3. When personBId is reached, map path to labels via LABEL_MAP
        // 4. Return labels from both perspectives
    }

    private static final int MAX_DEPTH = 8;

    private static final Map<List<String>, String[]> LABEL_MAP = Map.of(
        List.of("PARENT_OF"),                           new String[]{"Elternteil", "Kind"},
        List.of("PARENT_OF", "PARENT_OF"),              new String[]{"Großelternteil", "Enkelkind"},
        List.of("PARENT_OF", "PARENT_OF", "PARENT_OF"), new String[]{"Urgroßelternteil", "Urenkelkind"},
        List.of("SPOUSE_OF"),                           new String[]{"Ehegatte", "Ehegatte"},
        List.of("SIBLING_OF"),                          new String[]{"Geschwister", "Geschwister"},
        List.of("PARENT_OF", "SIBLING_OF"),             new String[]{"Onkel/Tante", "Nichte/Neffe"},
        List.of("SIBLING_OF", "PARENT_OF"),             new String[]{"Nichte/Neffe", "Onkel/Tante"},
        List.of("PARENT_OF", "PARENT_OF", "SIBLING_OF"),new String[]{"Großonkel/-tante", "Großnichte/-neffe"},
        List.of("PARENT_OF", "SPOUSE_OF"),              new String[]{"Schwiegereltern", "Schwiegerkind"},
        List.of("SIBLING_OF", "SPOUSE_OF"),             new String[]{"Schwager/Schwägerin", "Schwager/Schwägerin"}
        // Extend in implementation: cousins, half-siblings, etc.
        // Fallback for paths > 4 hops with no match: "Verwandte"
    );
}

Sibling derivation: Two persons sharing a PARENT_OF edge to the same parent are treated as siblings by BFS — no SIBLING_OF row needed. Explicit SIBLING_OF is stored only when parents are absent from the archive.


Stammbaum Page (/stammbaum)

Route

frontend/src/routes/stammbaum/+page.svelte + +page.server.ts.
Server load calls GET /api/network?family=true. SVG rendered client-side (needs browser dimensions).

Mode A — Family Tree (primary)

Custom generational layout — no external library needed for ≤30 nodes.

1. Find root nodes (no PARENT_OF edge points to them = no parents in archive)
2. BFS from roots, assigning generation depth to each node
3. Group each generation by family unit: couple = adjacent, children below midpoint
4. Compute x-positions with simple spacing algorithm
5. Render as SVG: rect nodes + cubic-bezier connectors for parent-child,
   horizontal line + midpoint dot for couples

Visual conventions:

  • Horizontal rows = generations ("Generation I", "Generation II", …)
  • Navy-border rect nodes for family members
  • Couple: horizontal line with midpoint dot
  • Parent→children: vertical drop from midpoint, horizontal spread, vertical to each child
  • Selected node: filled navy, white text → side panel opens

Side panel on node click:

  • Name, birth/death year
  • Document count + transcribed count (existing PersonStatsDTO)
  • Direct relationships
  • Top 5 derived relationships
  • "Zur Personenseite →" + "Beziehung hinzufügen" buttons

Mode B — Social Network (secondary, nice-to-have)

Toggle pill switches to D3-Force layout with all ~180 persons.

  • Import only d3-force (~8 kb gzipped); Svelte renders the SVG
  • Family member nodes: large circles, navy
  • Contact/institution nodes: small circles, grey
  • Family edges: solid navy · Social edges: dashed mint
  • Toggle state is not persisted; always opens in Mode A

Top bar

[Stammbaum | Alle Verbindungen]    [−] [100%] [+] [⊡ Reset]

Pan/zoom: native SVG viewBox manipulation on drag + wheel. No external library.


Relationship Editor (Person Edit Page)

New card on /persons/{id}/edit:

  • "Als Familienmitglied markieren" toggle — calls PATCH /api/persons/{id}/family-member
  • Direct relationships list[pill: type] [person name] [year range] [✕ delete]; delete calls DELETE
  • Add relationship form (always visible):
    [Dropdown: relation type] [PersonTypeahead] [+ Hinzufügen]POST
  • Derived relationships (read-only, collapsed by default) — from GET /api/persons/{id}/inferred-relationships

Document Detail Page — Relationship Badge

Trigger: Both sender and receiver have family_member = true.

The +page.server.ts calls GET /api/persons/{senderId}/relationship-to/{receiverId}. If 404: no badge, no error.

{#if data.inferredRelationship}
  <div class="rel-badge">
    <span>{data.document.sender.displayName}</span>
    <span class="rel-computed">{data.inferredRelationship.labelFromA}</span>
    <span class="rel-sep">·</span>
    <span class="rel-computed">{data.inferredRelationship.labelFromB}</span>
    <span>{data.document.receivers[0].displayName}</span>
  </div>
{/if}

Person Detail Page Changes

  • New "Beziehungen" card (above document list): direct + top derived relationships
  • Co-correspondent entries gain a "Briefwechsel mit [person]" link → /briefwechsel?senderId={current}&receiverId={other}

Nav Change

AppNav.svelte: replace /briefwechsel with /stammbaum, new Paraglide key nav_stammbaum in de.json / en.json / es.json.

/briefwechsel route is not deleted — stays accessible via direct URL, linked from person detail page.


Non-Functional Requirements

Category Requirement
Performance RelationshipInferenceService.infer() < 50 ms (graph ≤ 30 nodes)
Performance GET /api/network?family=true < 200 ms
Performance D3-Force 180 nodes → stable layout < 2 s on mid-range laptop
Accessibility SVG nodes: role="button", aria-label="{name}, {birth}–{death}"
Accessibility Side panel keyboard-reachable; focus moves to panel on keyboard activation
Responsive Min supported width 768 px (tablet). Mobile out of scope — transcriber persona device split
Security Relationship CRUD: WRITE_ALL. Reading: READ_ALL
i18n Relationship labels are Paraglide translation keys, not hardcoded strings

Out of Scope

  • Gender-specific labels (Vater/Mutter vs Elternteil) — no gender field on Person
  • Drag-to-create relationships on the canvas
  • Print / export as image or PDF
  • GEDCOM import/export
  • Public/embeddable tree view

Open Questions

ID Question Impact
OQ-02 Display label when path > 4 hops (second cousin etc.)? Suggest: "Verwandte" as fallback Badge UX
OQ-03 Should the badge also appear for social relationships (e.g. sender is COLLEAGUE of receiver)? Badge scope
OQ-04 Does Mode B (D3-Force network) ship with the initial release or as a follow-up? Scope / effort

Acceptance Criteria

Stammbaum page:

  • Given family members with PARENT_OF and SPOUSE_OF relationships exist, when I navigate to /stammbaum, then I see a generational SVG tree with at least 2 generations rendered correctly
  • Given I click a node, a side panel opens showing name, document count, and direct relationships
  • Given I click "Zur Personenseite →", I navigate to /persons/{id}
  • Given I toggle to "Alle Verbindungen", all ~180 persons appear in a force-directed graph

Relationship badge:

  • Given a document with a family-member sender + family-member receiver who are related, when I open the document detail page, then I see the badge with both labels
  • Given sender or receiver is not a family member → no badge
  • Given both are family members but no path exists → no badge

Relationship editor:

  • Given I toggle "Als Familienmitglied markieren" on a person edit page, the person appears on the Stammbaum on next load
  • Given I add PARENT_OF between Heinrich and Karl, then GET /api/persons/{karl}/relationship-to/{heinrichsSibling} returns the inferred Onkel/Tante label

Nav:

  • Given any logged-in user, the top nav shows "Stammbaum", not "Briefwechsel"
  • Given I navigate to /briefwechsel directly, the page still works
> **Spec status:** Ready for implementation > **Replaces:** `/briefwechsel` nav item > **Trigger:** Users reading letters ask "how are these two related?" — the app has no way to express this. Briefwechsel duplicates home search and is unused. --- ## Problem & Motivation Three converging user signals: 1. **"How are these two related?"** — Users reading a letter see sender and receiver and want to know their family relationship before reading (uncle writing to niece, son writing to mother). The app currently has no way to express this. 2. **Dead Briefwechsel page** — The bilateral correspondence filter duplicates the home search. Users don't understand when to use it. The nav slot is wasted. 3. **Differentiation from paperlessNGX** — The second unique feature after collaborative Kurrent transcription. A family-aware relationship graph with automatic inference is something a generic document archive cannot offer. --- ## Feature Overview Replace `/briefwechsel` in the nav with `/stammbaum`. Four interlocking parts: | Part | Where | What it does | |---|---|---| | **A — Data model** | PostgreSQL | `family_member` flag + `person_relationships` table | | **B — Stammbaum page** | `/stammbaum` | SVG family tree (generational layout); toggle to social network view | | **C — Relationship badge** | Document detail page | Shows computed relationship between sender and receiver | | **D — Relationship editor** | Person edit page | Toggle `family_member`, add/remove direct relationships | --- ## Data Model ### Migration: `V54__add_family_network.sql` ```sql -- Mark persons as family members (shown in the Stammbaum) ALTER TABLE persons ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE; -- All direct relationships between persons CREATE TABLE person_relationships ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE, related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE, relation_type VARCHAR(30) NOT NULL, from_year INTEGER, to_year INTEGER, notes TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT no_self_rel CHECK (person_id != related_person_id), CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type) ); CREATE INDEX idx_person_rel_person ON person_relationships(person_id); CREATE INDEX idx_person_rel_related ON person_relationships(related_person_id); ``` ### Relationship types Only **direct, first-degree** relationships are stored. All others are derived at query time. **Family (stored):** | Type | Meaning | Symmetric? | |---|---|---| | `PARENT_OF` | person_id is parent of related_person_id | No — inverse inferred as CHILD_OF | | `SPOUSE_OF` | Marriage or partnership | Yes — one row per couple, queries check both directions | | `SIBLING_OF` | Fallback only when parents are not in the archive | Yes | **Social (stored):** | Type | Meaning | |---|---| | `FRIEND` | Personal friendship | | `COLLEAGUE` | Work colleague | | `EMPLOYER` | person_id employed related_person_id | | `DOCTOR` | person_id was doctor of related_person_id | | `NEIGHBOR` | Neighbors | | `OTHER` | Free-form, use `notes` for details | **Derived (never stored):** grandparent, grandchild, uncle/aunt, niece/nephew, cousin, great-grandparent, in-laws — computed by `RelationshipInferenceService`. **Directionality rule:** `PARENT_OF` always stored from the parent's perspective. The inverse label "CHILD_OF" is computed in the application. Symmetric types accept either direction; queries check both. --- ## Backend ### New entity: `PersonRelationship` ```java @Entity @Table(name = "person_relationships") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class PersonRelationship { @Id @GeneratedValue(strategy = GenerationType.UUID) @Schema(requiredMode = REQUIRED) private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "person_id", nullable = false) private Person person; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "related_person_id", nullable = false) private Person relatedPerson; @Column(name = "relation_type", nullable = false) @Schema(requiredMode = REQUIRED) private String relationType; private Integer fromYear; private Integer toYear; private String notes; @CreationTimestamp @Schema(requiredMode = REQUIRED) private Instant createdAt; } ``` ### New DTOs ```java // Request: add a relationship public record CreateRelationshipRequest( UUID relatedPersonId, String relationType, // validated against allowed types Integer fromYear, Integer toYear, String notes ) {} // Response: a stored relationship edge public record RelationshipDTO( UUID id, UUID personId, String personDisplayName, UUID relatedPersonId, String relatedPersonDisplayName, String relationType, Integer fromYear, Integer toYear, String notes ) {} // Response: the full network graph public record NetworkDTO( List<PersonSummaryDTO> nodes, // reuses existing PersonSummaryDTO List<RelationshipDTO> edges ) {} // Response: computed relationship between two persons public record InferredRelationshipDTO( String labelFromA, // e.g. "Onkel/Tante" String labelFromB, // e.g. "Nichte/Neffe" List<String> path // edge sequence, for tooltip ) {} // Response: computed relationship paired with the related person public record InferredRelationshipWithPersonDTO( UUID relatedPersonId, String relatedPersonDisplayName, String labelFromA, String labelFromB, List<String> path ) {} ``` ### API endpoints ``` GET /api/network ?family=true → only nodes where family_member = true → NetworkDTO (used by Stammbaum page on load) GET /api/persons/{id}/relationships → List<RelationshipDTO> (stored direct relationships only) GET /api/persons/{id}/inferred-relationships → List<InferredRelationshipWithPersonDTO> (computed by RelationshipInferenceService; used for read-only derived sections) POST /api/persons/{id}/relationships body: CreateRelationshipRequest → RelationshipDTO @RequirePermission(WRITE_ALL) DELETE /api/persons/{id}/relationships/{relId} @RequirePermission(WRITE_ALL) PATCH /api/persons/{id}/family-member body: { "familyMember": true/false } @RequirePermission(WRITE_ALL) GET /api/persons/{aId}/relationship-to/{bId} → InferredRelationshipDTO | 404 if no path found (called by document detail page) ``` ### `RelationshipInferenceService` BFS on an in-memory graph of all `PARENT_OF`, `SPOUSE_OF`, and `SIBLING_OF` edges among family members. Graph has at most ~30 nodes — single-millisecond traversal. ```java @Service @RequiredArgsConstructor public class RelationshipInferenceService { private final PersonRelationshipRepository relationshipRepository; public Optional<InferredRelationshipDTO> infer(UUID personAId, UUID personBId) { // 1. Load all family edges (PARENT_OF, SPOUSE_OF, SIBLING_OF) // 2. BFS from personAId, tracking the edge-type sequence // 3. When personBId is reached, map path to labels via LABEL_MAP // 4. Return labels from both perspectives } private static final int MAX_DEPTH = 8; private static final Map<List<String>, String[]> LABEL_MAP = Map.of( List.of("PARENT_OF"), new String[]{"Elternteil", "Kind"}, List.of("PARENT_OF", "PARENT_OF"), new String[]{"Großelternteil", "Enkelkind"}, List.of("PARENT_OF", "PARENT_OF", "PARENT_OF"), new String[]{"Urgroßelternteil", "Urenkelkind"}, List.of("SPOUSE_OF"), new String[]{"Ehegatte", "Ehegatte"}, List.of("SIBLING_OF"), new String[]{"Geschwister", "Geschwister"}, List.of("PARENT_OF", "SIBLING_OF"), new String[]{"Onkel/Tante", "Nichte/Neffe"}, List.of("SIBLING_OF", "PARENT_OF"), new String[]{"Nichte/Neffe", "Onkel/Tante"}, List.of("PARENT_OF", "PARENT_OF", "SIBLING_OF"),new String[]{"Großonkel/-tante", "Großnichte/-neffe"}, List.of("PARENT_OF", "SPOUSE_OF"), new String[]{"Schwiegereltern", "Schwiegerkind"}, List.of("SIBLING_OF", "SPOUSE_OF"), new String[]{"Schwager/Schwägerin", "Schwager/Schwägerin"} // Extend in implementation: cousins, half-siblings, etc. // Fallback for paths > 4 hops with no match: "Verwandte" ); } ``` **Sibling derivation:** Two persons sharing a `PARENT_OF` edge to the same parent are treated as siblings by BFS — no `SIBLING_OF` row needed. Explicit `SIBLING_OF` is stored only when parents are absent from the archive. --- ## Stammbaum Page (`/stammbaum`) ### Route `frontend/src/routes/stammbaum/+page.svelte` + `+page.server.ts`. Server load calls `GET /api/network?family=true`. SVG rendered client-side (needs browser dimensions). ### Mode A — Family Tree (primary) Custom generational layout — no external library needed for ≤30 nodes. ``` 1. Find root nodes (no PARENT_OF edge points to them = no parents in archive) 2. BFS from roots, assigning generation depth to each node 3. Group each generation by family unit: couple = adjacent, children below midpoint 4. Compute x-positions with simple spacing algorithm 5. Render as SVG: rect nodes + cubic-bezier connectors for parent-child, horizontal line + midpoint dot for couples ``` **Visual conventions:** - Horizontal rows = generations ("Generation I", "Generation II", …) - Navy-border rect nodes for family members - Couple: horizontal line with midpoint dot - Parent→children: vertical drop from midpoint, horizontal spread, vertical to each child - Selected node: filled navy, white text → side panel opens **Side panel on node click:** - Name, birth/death year - Document count + transcribed count (existing `PersonStatsDTO`) - Direct relationships - Top 5 derived relationships - "Zur Personenseite →" + "Beziehung hinzufügen" buttons ### Mode B — Social Network (secondary, nice-to-have) Toggle pill switches to D3-Force layout with all ~180 persons. - Import only `d3-force` (~8 kb gzipped); Svelte renders the SVG - Family member nodes: large circles, navy - Contact/institution nodes: small circles, grey - Family edges: solid navy · Social edges: dashed mint - Toggle state is not persisted; always opens in Mode A ### Top bar ``` [Stammbaum | Alle Verbindungen] [−] [100%] [+] [⊡ Reset] ``` Pan/zoom: native SVG `viewBox` manipulation on drag + wheel. No external library. --- ## Relationship Editor (Person Edit Page) New card on `/persons/{id}/edit`: - **"Als Familienmitglied markieren" toggle** — calls `PATCH /api/persons/{id}/family-member` - **Direct relationships list** — `[pill: type] [person name] [year range] [✕ delete]`; delete calls `DELETE` - **Add relationship form** (always visible): `[Dropdown: relation type] [PersonTypeahead] [+ Hinzufügen]` → `POST` - **Derived relationships** (read-only, collapsed by default) — from `GET /api/persons/{id}/inferred-relationships` --- ## Document Detail Page — Relationship Badge **Trigger:** Both sender and receiver have `family_member = true`. The `+page.server.ts` calls `GET /api/persons/{senderId}/relationship-to/{receiverId}`. If 404: no badge, no error. ```svelte {#if data.inferredRelationship} <div class="rel-badge"> <span>{data.document.sender.displayName}</span> <span class="rel-computed">{data.inferredRelationship.labelFromA}</span> <span class="rel-sep">·</span> <span class="rel-computed">{data.inferredRelationship.labelFromB}</span> <span>{data.document.receivers[0].displayName}</span> </div> {/if} ``` --- ## Person Detail Page Changes - New **"Beziehungen" card** (above document list): direct + top derived relationships - Co-correspondent entries gain a **"Briefwechsel mit [person]"** link → `/briefwechsel?senderId={current}&receiverId={other}` --- ## Nav Change `AppNav.svelte`: replace `/briefwechsel` with `/stammbaum`, new Paraglide key `nav_stammbaum` in `de.json` / `en.json` / `es.json`. `/briefwechsel` route is **not deleted** — stays accessible via direct URL, linked from person detail page. --- ## Non-Functional Requirements | Category | Requirement | |---|---| | Performance | `RelationshipInferenceService.infer()` < 50 ms (graph ≤ 30 nodes) | | Performance | `GET /api/network?family=true` < 200 ms | | Performance | D3-Force 180 nodes → stable layout < 2 s on mid-range laptop | | Accessibility | SVG nodes: `role="button"`, `aria-label="{name}, {birth}–{death}"` | | Accessibility | Side panel keyboard-reachable; focus moves to panel on keyboard activation | | Responsive | Min supported width 768 px (tablet). Mobile out of scope — transcriber persona device split | | Security | Relationship CRUD: `WRITE_ALL`. Reading: `READ_ALL` | | i18n | Relationship labels are Paraglide translation keys, not hardcoded strings | --- ## Out of Scope - Gender-specific labels (Vater/Mutter vs Elternteil) — no gender field on Person - Drag-to-create relationships on the canvas - Print / export as image or PDF - GEDCOM import/export - Public/embeddable tree view --- ## Open Questions | ID | Question | Impact | |---|---|---| | OQ-02 | Display label when path > 4 hops (second cousin etc.)? Suggest: "Verwandte" as fallback | Badge UX | | OQ-03 | Should the badge also appear for social relationships (e.g. sender is `COLLEAGUE` of receiver)? | Badge scope | | OQ-04 | Does Mode B (D3-Force network) ship with the initial release or as a follow-up? | Scope / effort | --- ## Acceptance Criteria **Stammbaum page:** - Given family members with `PARENT_OF` and `SPOUSE_OF` relationships exist, when I navigate to `/stammbaum`, then I see a generational SVG tree with at least 2 generations rendered correctly - Given I click a node, a side panel opens showing name, document count, and direct relationships - Given I click "Zur Personenseite →", I navigate to `/persons/{id}` - Given I toggle to "Alle Verbindungen", all ~180 persons appear in a force-directed graph **Relationship badge:** - Given a document with a family-member sender + family-member receiver who are related, when I open the document detail page, then I see the badge with both labels - Given sender or receiver is not a family member → no badge - Given both are family members but no path exists → no badge **Relationship editor:** - Given I toggle "Als Familienmitglied markieren" on a person edit page, the person appears on the Stammbaum on next load - Given I add `PARENT_OF` between Heinrich and Karl, then `GET /api/persons/{karl}/relationship-to/{heinrichsSibling}` returns the inferred Onkel/Tante label **Nav:** - Given any logged-in user, the top nav shows "Stammbaum", not "Briefwechsel" - Given I navigate to `/briefwechsel` directly, the page still works
marcel added the P1-highfeaturepersonui labels 2026-04-27 10:01:14 +02:00
Author
Owner

OQ-02 resolved — relationship depth & cousin labels

Decision: Specific labels through 6 hops (second cousins). "Weitläufige Verwandte" fallback for 7–8 hops. No badge if no path found within 8 hops.

Badge restriction: The document detail badge only follows blood-relative edges (UP to parent / DOWN to child). SPOUSE edges are excluded from distant-path traversal — "first cousin's spouse's sibling's child" is technically computable but meaningless in a letter context.

Extended LABEL_MAP (replaces the placeholder in the spec)

Path A's label B's label
[UP] Elternteil Kind
[DOWN] Kind Elternteil
[SPOUSE] Ehegatte Ehegatte
[SIBLING] Geschwister Geschwister
[UP, UP] Großelternteil Enkelkind
[UP, DOWN] Geschwister (via Elternteil) Geschwister
[UP, SIBLING] Onkel/Tante Nichte/Neffe
[SIBLING, DOWN] Onkel/Tante Nichte/Neffe
[UP, SPOUSE] Schwiegereltern Schwiegerkind
[SIBLING, SPOUSE] Schwager/Schwägerin Schwager/Schwägerin
[UP, UP, UP] Urgroßelternteil Urenkelkind
[UP, UP, SIBLING] Großonkel/-tante Großnichte/-neffe
[UP, UP, DOWN, DOWN] 1. Cousin/Cousine 1. Cousin/Cousine
[UP, UP, UP, DOWN, DOWN] 1. Cousin einmal entfernt 1. Cousin einmal entfernt
[UP, UP, DOWN, DOWN, DOWN] 1. Cousin einmal entfernt 1. Cousin einmal entfernt
[UP, UP, UP, DOWN, DOWN, DOWN] 2. Cousin/Cousine 2. Cousin/Cousine
any path 7–8 hops Weitläufige Verwandte Weitläufige Verwandte

MAX_DEPTH = 8 stays (covers 3rd cousins for traversal; only labels differ beyond 6 hops).

## OQ-02 resolved — relationship depth & cousin labels **Decision:** Specific labels through 6 hops (second cousins). "Weitläufige Verwandte" fallback for 7–8 hops. No badge if no path found within 8 hops. **Badge restriction:** The document detail badge only follows blood-relative edges (UP to parent / DOWN to child). SPOUSE edges are excluded from distant-path traversal — "first cousin's spouse's sibling's child" is technically computable but meaningless in a letter context. ### Extended LABEL_MAP (replaces the placeholder in the spec) | Path | A's label | B's label | |---|---|---| | `[UP]` | Elternteil | Kind | | `[DOWN]` | Kind | Elternteil | | `[SPOUSE]` | Ehegatte | Ehegatte | | `[SIBLING]` | Geschwister | Geschwister | | `[UP, UP]` | Großelternteil | Enkelkind | | `[UP, DOWN]` | Geschwister (via Elternteil) | Geschwister | | `[UP, SIBLING]` | Onkel/Tante | Nichte/Neffe | | `[SIBLING, DOWN]` | Onkel/Tante | Nichte/Neffe | | `[UP, SPOUSE]` | Schwiegereltern | Schwiegerkind | | `[SIBLING, SPOUSE]` | Schwager/Schwägerin | Schwager/Schwägerin | | `[UP, UP, UP]` | Urgroßelternteil | Urenkelkind | | `[UP, UP, SIBLING]` | Großonkel/-tante | Großnichte/-neffe | | `[UP, UP, DOWN, DOWN]` | 1. Cousin/Cousine | 1. Cousin/Cousine | | `[UP, UP, UP, DOWN, DOWN]` | 1. Cousin einmal entfernt | 1. Cousin einmal entfernt | | `[UP, UP, DOWN, DOWN, DOWN]` | 1. Cousin einmal entfernt | 1. Cousin einmal entfernt | | `[UP, UP, UP, DOWN, DOWN, DOWN]` | 2. Cousin/Cousine | 2. Cousin/Cousine | | any path 7–8 hops | Weitläufige Verwandte | Weitläufige Verwandte | `MAX_DEPTH = 8` stays (covers 3rd cousins for traversal; only labels differ beyond 6 hops).
Author
Owner

OQ-03 resolved — social relationship badge

Decision: Badge appears for direct (1-hop) social relationships only. If sender and receiver have an explicit COLLEAGUE, FRIEND, DOCTOR, etc. edge stored, the badge shows that label (e.g. "Kollegen"). No inference across social edges — "friend of a friend" produces no badge.

Blood-relative inference (UP/DOWN paths) is unaffected.

OQ-04 resolved — Mode B (D3-Force network) deferred

Decision: Mode B (force-directed graph showing all ~180 persons) does not ship with the initial Stammbaum release. Tracked as a separate follow-up issue.

## OQ-03 resolved — social relationship badge **Decision:** Badge appears for direct (1-hop) social relationships only. If sender and receiver have an explicit `COLLEAGUE`, `FRIEND`, `DOCTOR`, etc. edge stored, the badge shows that label (e.g. "Kollegen"). No inference across social edges — "friend of a friend" produces no badge. Blood-relative inference (UP/DOWN paths) is unaffected. ## OQ-04 resolved — Mode B (D3-Force network) deferred **Decision:** Mode B (force-directed graph showing all ~180 persons) does not ship with the initial Stammbaum release. Tracked as a separate follow-up issue.
Author
Owner

🏛️ Markus Keller (@mkeller) — Application Architect

Architecture review of the Stammbaum spec. Six items worked through; all resolved. Decisions below are binding for implementation.


1. Module boundary — relationship is a first-class domain package

Decision: All relationship artifacts live in org.raddatz.familienarchiv.relationship — not inside person.

This includes:

  • PersonRelationship entity
  • PersonRelationshipRepository
  • RelationshipService
  • RelationshipInferenceService
  • All relationship-specific DTOs: RelationshipDTO, CreateRelationshipRequest, NetworkDTO, InferredRelationshipDTO, InferredRelationshipWithPersonDTO

PersonService contains zero relationship logic. Cross-domain calls follow the established pattern: RelationshipService calls PersonService to resolve person data; never the reverse.


2. Endpoint ownership — RelationshipController owns everything

Decision: A new RelationshipController in the relationship package owns all relationship endpoints:

GET    /api/network
GET    /api/persons/{id}/relationships
GET    /api/persons/{id}/inferred-relationships
GET    /api/persons/{aId}/relationship-to/{bId}
POST   /api/persons/{id}/relationships
DELETE /api/persons/{id}/relationships/{relId}
PATCH  /api/persons/{id}/family-member

PersonController gains zero new methods. This keeps PersonController clean and the relationship domain self-contained.


3. Symmetric uniqueness, divorce, and former spouses

Decision A — Partial unique index applies to SIBLING_OF only:

CREATE UNIQUE INDEX idx_symmetric_sibling
    ON person_relationships (
        LEAST(person_id::text, related_person_id::text),
        GREATEST(person_id::text, related_person_id::text),
        relation_type
    )
    WHERE relation_type = 'SIBLING_OF';

SPOUSE_OF is excluded from the partial index because the same two people could have married, divorced, and remarried — two separate rows with different from_year/to_year ranges. SPOUSE_OF uniqueness (no duplicate active marriages) is enforced at the application layer in RelationshipService.

Decision B — Former marriages use to_year; no schema change needed:

A SPOUSE_OF row with a non-null to_year is a former marriage. No EX_SPOUSE_OF type, no status field. Display implications:

  • Relationship editor shows year range: "Ehegatte (1920–1935)" vs "Ehegatte (1938–)"
  • Stammbaum SVG uses a dashed connector for SPOUSE_OF rows where to_year IS NOT NULL; solid connector for current marriages

Decision C — Inference is time-ignorant:

RelationshipInferenceService loads all edges unconditionally regardless of from_year/to_year. In a 1899–1950 historical archive, the fact that a relationship existed is worth preserving in inference. Time-aware filtering is not implemented.


4. BFS path abstraction — EXPLICIT IMPLEMENTATION CONTRACT

This is the most critical implementation detail. Get this wrong and labels silently produce incorrect results.

The LABEL_MAP in OQ-02 uses abstract step tokens — UP, DOWN, SIBLING, SPOUSE — not raw DB relation types. The BFS must emit these abstract tokens as it traverses edges. The mapping from DB edge to abstract token depends on direction of traversal, not just the stored type:

DB relation_type Traversal direction Abstract token emitted
PARENT_OF Following edge forward (parent → child) DOWN
PARENT_OF Following edge in reverse (child → parent) UP
SPOUSE_OF Either direction (symmetric) SPOUSE
SIBLING_OF Either direction (symmetric) SIBLING

The BFS graph must be built as a bidirectional adjacency structure: for every PARENT_OF(A, B) row, add two entries — A →DOWN→ B and B →UP→ A. For symmetric types, add both directions with the same token.

The LABEL_MAP key is the ordered List<String> of abstract tokens along the path from person A to person B. Example:

// Path: A is grandparent of B
// BFS emits: A →DOWN→ X →DOWN→ B
List.of("DOWN", "DOWN")  new String[]{"Großelternteil", "Enkelkind"}

// Path: A is uncle/aunt of B
// BFS emits: A →DOWN→ X →SIBLING→ Y ... wait — wrong direction
// Correct: A →UP→ parent →DOWN→ sibling_of_A →... no
// A is uncle of B means: B's parent is A's sibling
// BFS from A: A →SIBLING→ B's parent →DOWN→ B
List.of("SIBLING", "DOWN")  new String[]{"Onkel/Tante", "Nichte/Neffe"}

Felix: build the adjacency map first, write unit tests for the abstract token sequence for each label in the LABEL_MAP before touching the label lookup. A test that asserts infer(heinrich, niece) emits path ["SIBLING", "DOWN"] catches this class of bug immediately.


5. NetworkDTO nodes — identity only

Decision: NetworkDTO.nodes carries identity-only fields. No document counts, no transcription stats.

public record PersonNodeDTO(
    UUID id,
    String displayName,
    Integer birthYear,
    Integer deathYear,
    boolean familyMember
) {}

If PersonSummaryDTO carries additional fields (stats, aliases), the network endpoint uses this lighter PersonNodeDTO projection instead. The < 200 ms requirement for GET /api/network depends on this — no aggregation queries on the node fetch.


6. BFS loading — two queries maximum, no lazy traversal

Decision: Every call to RelationshipInferenceService and every response from GET /api/network is backed by at most two queries:

  1. One bulk query for nodes (persons)
  2. One bulk query for edges (relationships)
// Correct — one query for the full family graph
List<PersonRelationship> edges = relationshipRepository
    .findAllByRelationTypeIn(List.of("PARENT_OF", "SPOUSE_OF", "SIBLING_OF"));

// Wrong — N+1 via JPA lazy traversal
edges.forEach(e -> e.getPerson().getDisplayName()); // triggers N selects

All @ManyToOne associations on PersonRelationship are FetchType.LAZY. The repository provides bulk-fetch methods. No caching is needed at this graph size — a fresh bulk load on every inference call is fast enough and avoids cache invalidation complexity.


Overall read

Solid spec. The data model is appropriate for the domain and the graph size makes in-memory BFS the right call. The main implementation risk is item 4 — the BFS direction/abstraction layer. Everything else is straightforward given the decisions above.

## 🏛️ Markus Keller (@mkeller) — Application Architect Architecture review of the Stammbaum spec. Six items worked through; all resolved. Decisions below are binding for implementation. --- ### 1. Module boundary — `relationship` is a first-class domain package **Decision:** All relationship artifacts live in `org.raddatz.familienarchiv.relationship` — not inside `person`. This includes: - `PersonRelationship` entity - `PersonRelationshipRepository` - `RelationshipService` - `RelationshipInferenceService` - All relationship-specific DTOs: `RelationshipDTO`, `CreateRelationshipRequest`, `NetworkDTO`, `InferredRelationshipDTO`, `InferredRelationshipWithPersonDTO` `PersonService` contains zero relationship logic. Cross-domain calls follow the established pattern: `RelationshipService` calls `PersonService` to resolve person data; never the reverse. --- ### 2. Endpoint ownership — `RelationshipController` owns everything **Decision:** A new `RelationshipController` in the `relationship` package owns all relationship endpoints: ``` GET /api/network GET /api/persons/{id}/relationships GET /api/persons/{id}/inferred-relationships GET /api/persons/{aId}/relationship-to/{bId} POST /api/persons/{id}/relationships DELETE /api/persons/{id}/relationships/{relId} PATCH /api/persons/{id}/family-member ``` `PersonController` gains zero new methods. This keeps `PersonController` clean and the relationship domain self-contained. --- ### 3. Symmetric uniqueness, divorce, and former spouses **Decision A — Partial unique index applies to `SIBLING_OF` only:** ```sql CREATE UNIQUE INDEX idx_symmetric_sibling ON person_relationships ( LEAST(person_id::text, related_person_id::text), GREATEST(person_id::text, related_person_id::text), relation_type ) WHERE relation_type = 'SIBLING_OF'; ``` `SPOUSE_OF` is excluded from the partial index because the same two people could have married, divorced, and remarried — two separate rows with different `from_year`/`to_year` ranges. `SPOUSE_OF` uniqueness (no duplicate active marriages) is enforced at the application layer in `RelationshipService`. **Decision B — Former marriages use `to_year`; no schema change needed:** A `SPOUSE_OF` row with a non-null `to_year` is a former marriage. No `EX_SPOUSE_OF` type, no `status` field. Display implications: - Relationship editor shows year range: `"Ehegatte (1920–1935)"` vs `"Ehegatte (1938–)"` - Stammbaum SVG uses a **dashed connector** for `SPOUSE_OF` rows where `to_year IS NOT NULL`; solid connector for current marriages **Decision C — Inference is time-ignorant:** `RelationshipInferenceService` loads all edges unconditionally regardless of `from_year`/`to_year`. In a 1899–1950 historical archive, the fact that a relationship existed is worth preserving in inference. Time-aware filtering is not implemented. --- ### 4. BFS path abstraction — EXPLICIT IMPLEMENTATION CONTRACT **This is the most critical implementation detail. Get this wrong and labels silently produce incorrect results.** The `LABEL_MAP` in OQ-02 uses abstract step tokens — `UP`, `DOWN`, `SIBLING`, `SPOUSE` — not raw DB relation types. The BFS must emit these abstract tokens as it traverses edges. The mapping from DB edge to abstract token depends on **direction of traversal**, not just the stored type: | DB `relation_type` | Traversal direction | Abstract token emitted | |---|---|---| | `PARENT_OF` | Following edge forward (parent → child) | `DOWN` | | `PARENT_OF` | Following edge in reverse (child → parent) | `UP` | | `SPOUSE_OF` | Either direction (symmetric) | `SPOUSE` | | `SIBLING_OF` | Either direction (symmetric) | `SIBLING` | The BFS graph must be built as a **bidirectional adjacency structure**: for every `PARENT_OF(A, B)` row, add two entries — `A →DOWN→ B` and `B →UP→ A`. For symmetric types, add both directions with the same token. The `LABEL_MAP` key is the ordered `List<String>` of abstract tokens along the path from person A to person B. Example: ```java // Path: A is grandparent of B // BFS emits: A →DOWN→ X →DOWN→ B List.of("DOWN", "DOWN") → new String[]{"Großelternteil", "Enkelkind"} // Path: A is uncle/aunt of B // BFS emits: A →DOWN→ X →SIBLING→ Y ... wait — wrong direction // Correct: A →UP→ parent →DOWN→ sibling_of_A →... no // A is uncle of B means: B's parent is A's sibling // BFS from A: A →SIBLING→ B's parent →DOWN→ B List.of("SIBLING", "DOWN") → new String[]{"Onkel/Tante", "Nichte/Neffe"} ``` Felix: build the adjacency map first, write unit tests for the abstract token sequence for each label in the LABEL_MAP before touching the label lookup. A test that asserts `infer(heinrich, niece)` emits path `["SIBLING", "DOWN"]` catches this class of bug immediately. --- ### 5. `NetworkDTO` nodes — identity only **Decision:** `NetworkDTO.nodes` carries identity-only fields. No document counts, no transcription stats. ```java public record PersonNodeDTO( UUID id, String displayName, Integer birthYear, Integer deathYear, boolean familyMember ) {} ``` If `PersonSummaryDTO` carries additional fields (stats, aliases), the network endpoint uses this lighter `PersonNodeDTO` projection instead. The `< 200 ms` requirement for `GET /api/network` depends on this — no aggregation queries on the node fetch. --- ### 6. BFS loading — two queries maximum, no lazy traversal **Decision:** Every call to `RelationshipInferenceService` and every response from `GET /api/network` is backed by at most two queries: 1. One bulk query for nodes (persons) 2. One bulk query for edges (relationships) ```java // Correct — one query for the full family graph List<PersonRelationship> edges = relationshipRepository .findAllByRelationTypeIn(List.of("PARENT_OF", "SPOUSE_OF", "SIBLING_OF")); // Wrong — N+1 via JPA lazy traversal edges.forEach(e -> e.getPerson().getDisplayName()); // triggers N selects ``` All `@ManyToOne` associations on `PersonRelationship` are `FetchType.LAZY`. The repository provides bulk-fetch methods. No caching is needed at this graph size — a fresh bulk load on every inference call is fast enough and avoids cache invalidation complexity. --- ### Overall read Solid spec. The data model is appropriate for the domain and the graph size makes in-memory BFS the right call. The main implementation risk is item 4 — the BFS direction/abstraction layer. Everything else is straightforward given the decisions above.
Author
Owner

UI Specs — committed in 33ca2df4

Three standalone HTML spec files have been added to docs/specs/. Open in any browser to review.

stammbaum-tree-spec.html

The /stammbaum page — generational SVG tree (Gen I–III), side panel with direkte/abgeleitete Beziehungen, and the inline "Beziehung hinzufügen" form. Covers:

  • Desktop (1280 px) light + dark at 65 % scale
  • Tablet (768 px) + mobile (375 px) stacked layouts
  • Side panel resting state vs. add-form expanded
  • Token table (light + dark design system values)

stammbaum-doc-badge-spec.html

The relationship badge on the document detail page — inline pill placement (decision from OQ-03 discussion): a small mint pill sits directly after each person's name in the PERSONEN column. No separate badge strip.

  • Desktop / tablet / mobile
  • Light + dark

stammbaum-person-edit-spec.html

The new "Stammbaum & Beziehungen" card on /persons/{id}/edit:

  • "Als Familienmitglied markieren" toggle
  • Direct relationship list with delete buttons and year ranges
  • Inline add-form (type dropdown + PersonTypeahead + Von/Bis Jahr fields)
  • Derived relationships section (collapsed, read-only)
  • Desktop / tablet / mobile, light + dark

Design decisions reflected in specs

Decision Source
Badge: inline pill per person, not a full-width strip OQ-03 discussion
Doc count removed from tree nodes (lazy-loaded in side panel) OQ-04 discussion
"Beziehung hinzufügen" button below direct-rel list, not panel footer Design review
Von/Bis Jahr fields in add-relationship form Design review
Mode B (D3-Force) deferred OQ-04 resolved
## UI Specs — committed in `33ca2df4` Three standalone HTML spec files have been added to `docs/specs/`. Open in any browser to review. ### [`stammbaum-tree-spec.html`](http://heim-nas:3005/marcel/familienarchiv/src/branch/main/docs/specs/stammbaum-tree-spec.html) The `/stammbaum` page — generational SVG tree (Gen I–III), side panel with direkte/abgeleitete Beziehungen, and the inline "Beziehung hinzufügen" form. Covers: - Desktop (1280 px) light + dark at 65 % scale - Tablet (768 px) + mobile (375 px) stacked layouts - Side panel resting state vs. add-form expanded - Token table (light + dark design system values) ### [`stammbaum-doc-badge-spec.html`](http://heim-nas:3005/marcel/familienarchiv/src/branch/main/docs/specs/stammbaum-doc-badge-spec.html) The relationship badge on the document detail page — **inline pill** placement (decision from OQ-03 discussion): a small mint pill sits directly after each person's name in the PERSONEN column. No separate badge strip. - Desktop / tablet / mobile - Light + dark ### [`stammbaum-person-edit-spec.html`](http://heim-nas:3005/marcel/familienarchiv/src/branch/main/docs/specs/stammbaum-person-edit-spec.html) The new "Stammbaum & Beziehungen" card on `/persons/{id}/edit`: - "Als Familienmitglied markieren" toggle - Direct relationship list with delete buttons and year ranges - Inline add-form (type dropdown + PersonTypeahead + Von/Bis Jahr fields) - Derived relationships section (collapsed, read-only) - Desktop / tablet / mobile, light + dark ### Design decisions reflected in specs | Decision | Source | |---|---| | Badge: inline pill per person, not a full-width strip | OQ-03 discussion | | Doc count removed from tree nodes (lazy-loaded in side panel) | OQ-04 discussion | | "Beziehung hinzufügen" button below direct-rel list, not panel footer | Design review | | Von/Bis Jahr fields in add-relationship form | Design review | | Mode B (D3-Force) deferred | OQ-04 resolved |
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist

UI/UX review of the Stammbaum spec — six interaction and accessibility gaps worked through. All resolved. Decisions below are binding for implementation.


1. SVG node touch targets — minimum dimensions enforced by layout algorithm

Decision: Every tree node rect has a minimum size of 140×48 px, enforced by the generational layout algorithm. Spacing between nodes is computed around this floor, not the other way around. If the tree overflows horizontally, the SVG viewBox scrolls — that is acceptable. No invisible hit-area overlay needed.


2. Side panel focus management — full contract

Decision:

  • Keyboard activation of a tree node (Enter/Space) → focus moves to the "Zur Personenseite →" link inside the panel (first meaningful interactive element)
  • No focus trap — Tab cycles from panel contents back into tree nodes naturally
  • Escape / outside click → panel closes → focus returns to the activating node
  • Tree node buttons carry aria-expanded="true/false" to communicate panel state to assistive technology

3. Keyboard panning — Tab-driven, viewBox follows focus

Decision: No explicit arrow-key pan controls. Tab moves between tree nodes in DOM order (left-to-right, generation by generation). When focus moves to an off-screen node, the SVG viewBox adjusts automatically to center the focused node in the viewport. The [−] [+] [⊡ Reset] zoom buttons in the top bar remain as specified.


4. Empty state on /stammbaum

Decision: When no person has family_member = true, the SVG area shows a centered empty state:

[tree icon in brand-navy]

Noch keine Familienmitglieder

Markiere Personen als Familienmitglieder auf ihrer
Bearbeitungsseite, um sie hier anzuzeigen.

[→ Zur Personenliste]

The link navigates to /persons. No modal, no toast — centered in the canvas area using the standard card pattern.


5. Relationship type i18n keys

Decision: All stored relation types get Paraglide keys following the existing de.json naming convention. The add-relationship form dropdown groups family types first, then social types, with a visual separator.

Stored type Paraglide key German label
PARENT_OF relation_parent_of Elternteil von
SPOUSE_OF relation_spouse_of Ehegatte von
SIBLING_OF relation_sibling_of Geschwister von
FRIEND relation_friend Freund/Freundin
COLLEAGUE relation_colleague Kollege/Kollegin
EMPLOYER relation_employer Arbeitgeber von
DOCTOR relation_doctor Arzt/Ärztin von
NEIGHBOR relation_neighbor Nachbar/Nachbarin
OTHER relation_other Sonstige

Inferred/derived labels (from OQ-02) follow the same pattern: relation_inferred_parent, relation_inferred_grandparent, etc.

Keys must be added to de.json, en.json, and es.json at implementation time.


6. Year field validation UX (Von/Bis Jahr in add-relationship form)

Decision:

  • Bis before Von (e.g., Von: 1935, Bis: 1920) → blocking inline error below the Bis field: "Bis-Jahr muss nach Von-Jahr liegen". Blocks submit.
  • Both fields empty → valid. A relationship without a known date range is the common case.
  • No soft warning for years outside the 1899–1950 archive range — persons' lifespans legitimately extend beyond the document corpus.

Visual treatment: inline error text in text-red-700 below the field, paired with aria-describedby linking the field to its error. Color change alone is insufficient — text label is required for color-blind users.


Overall read

The spec is implementation-ready from a UI/UX perspective. The main risk area remains the SVG interaction layer — touch targets, focus management, and viewBox scroll-on-focus are all easy to skip under time pressure and hard to retrofit. I'd recommend Felix implements and verifies these three together before the layout algorithm is considered done.

## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist UI/UX review of the Stammbaum spec — six interaction and accessibility gaps worked through. All resolved. Decisions below are binding for implementation. --- ### 1. SVG node touch targets — minimum dimensions enforced by layout algorithm **Decision:** Every tree node rect has a minimum size of **140×48 px**, enforced by the generational layout algorithm. Spacing between nodes is computed around this floor, not the other way around. If the tree overflows horizontally, the SVG viewBox scrolls — that is acceptable. No invisible hit-area overlay needed. --- ### 2. Side panel focus management — full contract **Decision:** - Keyboard activation of a tree node (Enter/Space) → focus moves to the **"Zur Personenseite →" link** inside the panel (first meaningful interactive element) - **No focus trap** — Tab cycles from panel contents back into tree nodes naturally - **Escape / outside click** → panel closes → focus returns to the activating node - Tree node buttons carry `aria-expanded="true/false"` to communicate panel state to assistive technology --- ### 3. Keyboard panning — Tab-driven, viewBox follows focus **Decision:** No explicit arrow-key pan controls. Tab moves between tree nodes in DOM order (left-to-right, generation by generation). When focus moves to an off-screen node, the SVG viewBox adjusts automatically to center the focused node in the viewport. The `[−] [+] [⊡ Reset]` zoom buttons in the top bar remain as specified. --- ### 4. Empty state on `/stammbaum` **Decision:** When no person has `family_member = true`, the SVG area shows a centered empty state: ``` [tree icon in brand-navy] Noch keine Familienmitglieder Markiere Personen als Familienmitglieder auf ihrer Bearbeitungsseite, um sie hier anzuzeigen. [→ Zur Personenliste] ``` The link navigates to `/persons`. No modal, no toast — centered in the canvas area using the standard card pattern. --- ### 5. Relationship type i18n keys **Decision:** All stored relation types get Paraglide keys following the existing `de.json` naming convention. The add-relationship form dropdown groups family types first, then social types, with a visual separator. | Stored type | Paraglide key | German label | |---|---|---| | `PARENT_OF` | `relation_parent_of` | Elternteil von | | `SPOUSE_OF` | `relation_spouse_of` | Ehegatte von | | `SIBLING_OF` | `relation_sibling_of` | Geschwister von | | `FRIEND` | `relation_friend` | Freund/Freundin | | `COLLEAGUE` | `relation_colleague` | Kollege/Kollegin | | `EMPLOYER` | `relation_employer` | Arbeitgeber von | | `DOCTOR` | `relation_doctor` | Arzt/Ärztin von | | `NEIGHBOR` | `relation_neighbor` | Nachbar/Nachbarin | | `OTHER` | `relation_other` | Sonstige | Inferred/derived labels (from OQ-02) follow the same pattern: `relation_inferred_parent`, `relation_inferred_grandparent`, etc. Keys must be added to `de.json`, `en.json`, and `es.json` at implementation time. --- ### 6. Year field validation UX (Von/Bis Jahr in add-relationship form) **Decision:** - **Bis before Von** (e.g., Von: 1935, Bis: 1920) → blocking inline error below the Bis field: `"Bis-Jahr muss nach Von-Jahr liegen"`. Blocks submit. - **Both fields empty** → valid. A relationship without a known date range is the common case. - No soft warning for years outside the 1899–1950 archive range — persons' lifespans legitimately extend beyond the document corpus. Visual treatment: inline error text in `text-red-700` below the field, paired with `aria-describedby` linking the field to its error. Color change alone is insufficient — text label is required for color-blind users. --- ### Overall read The spec is implementation-ready from a UI/UX perspective. The main risk area remains the SVG interaction layer — touch targets, focus management, and viewBox scroll-on-focus are all easy to skip under time pressure and hard to retrofit. I'd recommend Felix implements and verifies these three together before the layout algorithm is considered done.
Author
Owner

🧑‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer

Developer review of the Stammbaum spec — six implementation questions worked through. All resolved. Decisions below are binding for implementation.


1. relationType — Java enum, not String

Decision: Define RelationType as a Java enum in the relationship package. Use @Enumerated(EnumType.STRING) on the PersonRelationship entity field. Validate CreateRelationshipRequest.relationType at the controller boundary (catch IllegalArgumentException from RelationType.valueOf(), throw ResponseStatusException(BAD_REQUEST)).

Rationale: nine known values, used in switch-style BFS token mapping — a String field allows invalid rows to silently reach the inference engine.


2. PATCH /family-member — cross-domain call via PersonService

Decision: Add PersonService.setFamilyMember(UUID personId, boolean familyMember). RelationshipService calls it; RelationshipController calls RelationshipService. RelationshipController never touches PersonRepository directly — consistent with Markus's boundary rules and the existing DocumentService → PersonService pattern.


3. BFS test coverage — all 17 LABEL_MAP entries

Decision: Write one unit test per LABEL_MAP entry asserting the exact abstract token sequence emitted by the BFS (e.g. infer(uncle, niece)[SIBLING, DOWN]), plus one test for the no-path case. 18 tests total. All 17 entries, not a representative subset — this is the primary regression net for the most likely class of bug in this feature (per Markus item 4). Tests are written before the label lookup is implemented.


4. Missing acceptance criterion — social relationship badge

Decision: The OQ-03 resolution added social badge scope but the AC section was not updated. The following AC is binding:

Given a document where sender and receiver share a direct social relationship (e.g. COLLEAGUE), when I open the document detail page, then the badge shows the social label (e.g. "Kollegen"). No badge appears for indirect social paths (friend-of-friend).


5. Dark mode — automatic via semantic tokens

Decision: No extra work needed. The project has a full semantic token system (--c-surface, --c-ink, --c-primary, --c-accent, etc.) covering both prefers-color-scheme: dark and data-theme='dark'. SVG elements in the Stammbaum page must use var(--c-primary) / var(--c-ink) / var(--c-accent) — never hardcoded hex — to get dark mode for free.


6. Implementation order — backend-complete first (Option A)

Decision: Full backend before any frontend work:

  1. Flyway migration V54__add_family_network.sql
  2. RelationType enum + PersonRelationship entity
  3. PersonRelationshipRepository + PersonService.setFamilyMember()
  4. RelationshipService + RelationshipInferenceService (18 BFS tests first, red → green)
  5. RelationshipController
  6. Rebuild JAR → regenerate API types (npm run generate:api)
  7. All four frontend surfaces: Stammbaum page, person edit card, document badge, nav change

Overall read

Spec is implementation-ready. The main execution risk remains the BFS direction/abstraction layer (Markus item 4) — the 18 unit tests in step 4 are the primary safety net for this. Everything else is well-defined. Ready to start.

## 🧑‍💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer Developer review of the Stammbaum spec — six implementation questions worked through. All resolved. Decisions below are binding for implementation. --- ### 1. `relationType` — Java enum, not String **Decision:** Define `RelationType` as a Java enum in the `relationship` package. Use `@Enumerated(EnumType.STRING)` on the `PersonRelationship` entity field. Validate `CreateRelationshipRequest.relationType` at the controller boundary (catch `IllegalArgumentException` from `RelationType.valueOf()`, throw `ResponseStatusException(BAD_REQUEST)`). Rationale: nine known values, used in switch-style BFS token mapping — a `String` field allows invalid rows to silently reach the inference engine. --- ### 2. `PATCH /family-member` — cross-domain call via `PersonService` **Decision:** Add `PersonService.setFamilyMember(UUID personId, boolean familyMember)`. `RelationshipService` calls it; `RelationshipController` calls `RelationshipService`. `RelationshipController` never touches `PersonRepository` directly — consistent with Markus's boundary rules and the existing `DocumentService → PersonService` pattern. --- ### 3. BFS test coverage — all 17 LABEL_MAP entries **Decision:** Write one unit test per LABEL_MAP entry asserting the exact abstract token sequence emitted by the BFS (e.g. `infer(uncle, niece)` → `[SIBLING, DOWN]`), plus one test for the no-path case. 18 tests total. All 17 entries, not a representative subset — this is the primary regression net for the most likely class of bug in this feature (per Markus item 4). Tests are written before the label lookup is implemented. --- ### 4. Missing acceptance criterion — social relationship badge **Decision:** The OQ-03 resolution added social badge scope but the AC section was not updated. The following AC is binding: > *Given a document where sender and receiver share a direct social relationship (e.g. `COLLEAGUE`), when I open the document detail page, then the badge shows the social label (e.g. "Kollegen"). No badge appears for indirect social paths (friend-of-friend).* --- ### 5. Dark mode — automatic via semantic tokens **Decision:** No extra work needed. The project has a full semantic token system (`--c-surface`, `--c-ink`, `--c-primary`, `--c-accent`, etc.) covering both `prefers-color-scheme: dark` and `data-theme='dark'`. SVG elements in the Stammbaum page must use `var(--c-primary)` / `var(--c-ink)` / `var(--c-accent)` — never hardcoded hex — to get dark mode for free. --- ### 6. Implementation order — backend-complete first (Option A) **Decision:** Full backend before any frontend work: 1. Flyway migration `V54__add_family_network.sql` 2. `RelationType` enum + `PersonRelationship` entity 3. `PersonRelationshipRepository` + `PersonService.setFamilyMember()` 4. `RelationshipService` + `RelationshipInferenceService` (18 BFS tests first, red → green) 5. `RelationshipController` 6. Rebuild JAR → regenerate API types (`npm run generate:api`) 7. All four frontend surfaces: Stammbaum page, person edit card, document badge, nav change --- ### Overall read Spec is implementation-ready. The main execution risk remains the BFS direction/abstraction layer (Markus item 4) — the 18 unit tests in step 4 are the primary safety net for this. Everything else is well-defined. Ready to start.
Author
Owner

🔧 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Verdict: Approved

Zero new infrastructure. Reviewing purely for migration safety, index hygiene, and the one performance NFR.


Migration — V54

The SQL in the spec is clean. A few checkpoints for implementation:

  1. Confirm V53 exists before creating V54__add_family_network.sql. A version gap causes Flyway to halt on startup in production. Check backend/src/main/resources/db/migration/ before naming the file.
  2. Cascade coverageON DELETE CASCADE on both FK columns . Deleting a person automatically removes all their relationship rows. No orphan cleanup job needed.
  3. Index count is correctidx_person_rel_person on person_id and idx_person_rel_related on related_person_id cover both lookup directions. The symmetric sibling partial unique index (Markus decision 3) adds a third. Three total — not expensive for a table of this size.
  4. gen_random_uuid() PK — consistent with the existing schema. No sequence introduced.

Performance NFR

The spec states GET /api/network?family=true < 200 ms. Add this to the k6 smoke suite at implementation time:

// k6 smoke: relationship network endpoint
const res = http.get(`${BASE_URL}/api/network?family=true`);
check(res, { 'network < 200ms': r => r.timings.duration < 200 });

No need to verify this at spec stage — the two-query design (Markus decision 6) makes this easily achievable. Just make sure it lands in the smoke test before the ticket closes.

Notes field — TEXT unbounded

From a storage perspective, unlimited TEXT on a table with potentially hundreds of rows is not a concern at this scale. The data integrity question (is it too big?) is for Nora and Elicit to call.

Overall

Cleanest infra footprint of any feature in recent history. No new services, no new Docker volumes, one straightforward migration. Ready to go.

## 🔧 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer **Verdict: ✅ Approved** Zero new infrastructure. Reviewing purely for migration safety, index hygiene, and the one performance NFR. --- ### Migration — V54 The SQL in the spec is clean. A few checkpoints for implementation: 1. **Confirm V53 exists** before creating `V54__add_family_network.sql`. A version gap causes Flyway to halt on startup in production. Check `backend/src/main/resources/db/migration/` before naming the file. 2. **Cascade coverage** — `ON DELETE CASCADE` on both FK columns ✅. Deleting a person automatically removes all their relationship rows. No orphan cleanup job needed. 3. **Index count is correct** — `idx_person_rel_person` on `person_id` and `idx_person_rel_related` on `related_person_id` cover both lookup directions. The symmetric sibling partial unique index (Markus decision 3) adds a third. Three total — not expensive for a table of this size. 4. **`gen_random_uuid()` PK** — consistent with the existing schema. No sequence introduced. ✅ ### Performance NFR The spec states `GET /api/network?family=true < 200 ms`. Add this to the k6 smoke suite at implementation time: ```javascript // k6 smoke: relationship network endpoint const res = http.get(`${BASE_URL}/api/network?family=true`); check(res, { 'network < 200ms': r => r.timings.duration < 200 }); ``` No need to verify this at spec stage — the two-query design (Markus decision 6) makes this easily achievable. Just make sure it lands in the smoke test before the ticket closes. ### Notes field — `TEXT` unbounded From a storage perspective, unlimited `TEXT` on a table with potentially hundreds of rows is not a concern at this scale. The data integrity question (is it too big?) is for Nora and Elicit to call. ### Overall Cleanest infra footprint of any feature in recent history. No new services, no new Docker volumes, one straightforward migration. Ready to go.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: ⚠️ Approved with concerns

Two blockers and two suggestions. The permission model is consistent with the existing codebase — WRITE_ALL for mutations, READ_ALL implied for reads. The main risk is object-level authorization on the delete endpoint and a data integrity gap in the parent relationship model.


🚫 Blocker 1 — IDOR on DELETE /api/persons/{id}/relationships/{relId}

Any user with WRITE_ALL can delete any relationship in the database by supplying an arbitrary relId. The path param {id} is not validated as the owner of relId anywhere in the spec.

Fix — add an ownership check in RelationshipService.deleteRelationship():

PersonRelationship rel = relationshipRepository.findById(relId)
    .orElseThrow(() -> DomainException.notFound(ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship: " + relId));

boolean isPrincipal = rel.getPerson().getId().equals(personId)
    || rel.getRelatedPerson().getId().equals(personId);
if (!isPrincipal) {
    throw DomainException.forbidden("Relationship " + relId + " does not belong to person " + personId);
}

Symmetric types (SIBLING_OF, SPOUSE_OF) can be accessed from either person's page, so checking both sides is correct.

Test to accompany this fix (red first):

@Test
void delete_returns403_when_relId_belongs_to_different_person() {
    UUID unrelatedPersonId = UUID.randomUUID();
    // relId belongs to personA/personB, call with unrelatedPersonId
    assertThatThrownBy(() -> relationshipService.deleteRelationship(unrelatedPersonId, relId))
        .isInstanceOf(DomainException.class)
        .extracting("httpStatus").isEqualTo(HttpStatus.FORBIDDEN);
}

🚫 Blocker 2 — Circular PARENT_OF not prevented by schema

The unique_rel constraint allows (A, B, PARENT_OF) and (B, A, PARENT_OF) to coexist. The DB sees two distinct rows. The BFS has MAX_DEPTH = 8 which prevents an infinite loop, but the graph is now corrupt: A is B's parent AND B is A's parent.

Fix — check for the reverse edge in RelationshipService.addRelationship() before persisting:

if (type == RelationType.PARENT_OF) {
    boolean reverseExists = relationshipRepository
        .existsByPersonIdAndRelatedPersonIdAndRelationType(
            request.relatedPersonId(), personId, RelationType.PARENT_OF);
    if (reverseExists) {
        throw DomainException.conflict(
            ErrorCode.CIRCULAR_RELATIONSHIP,
            "Cannot add PARENT_OF: reverse edge already exists");
    }
}

This is application-layer enforcement. A DB partial unique index for this case would require normalising asymmetric pairs — overkill. Service-layer check is the right call.

Add CIRCULAR_RELATIONSHIP to ErrorCode.java and mirror it in errors.ts.


💡 Suggestion 3 — notes field — unbounded TEXT

The spec defines notes TEXT with no length cap. A user with WRITE_ALL can store arbitrarily large content. Recommend:

-- in V54:
notes VARCHAR(2000)
// CreateRelationshipRequest
@Size(max = 2000)
String notes

2,000 characters covers any realistic relationship annotation in a historical family archive.


💡 Suggestion 4 — Never log notes content at INFO level

The notes field contains personal/historical data. Ensure RelationshipService never logs this field's content at INFO/DEBUG level. Structured logging with parameterised SLF4J is sufficient:

logger.info("Relationship created: {} --{}-- {}", personId, type, relatedPersonId);
// not: logger.info("Relationship created: {}", rel);  // would include notes via toString()

Check that PersonRelationship's Lombok @Data-generated toString() doesn't reach log output (it will include notes). Either exclude the field from toString or suppress it in @ToString(exclude = "notes").


What's correct

  • Permission model: @RequirePermission(WRITE_ALL) on all mutating endpoints. READ_ALL implied on reads.
  • RelationType as enum (Felix decision 1) — typo-safe, validated at controller boundary.
  • no_self_rel CHECK constraint at DB layer.
  • MAX_DEPTH = 8 bounds the BFS.
  • ON DELETE CASCADE handles person deletion cleanly — no orphaned relationship rows.
## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ⚠️ Approved with concerns** Two blockers and two suggestions. The permission model is consistent with the existing codebase — `WRITE_ALL` for mutations, `READ_ALL` implied for reads. The main risk is object-level authorization on the delete endpoint and a data integrity gap in the parent relationship model. --- ### 🚫 Blocker 1 — IDOR on `DELETE /api/persons/{id}/relationships/{relId}` Any user with `WRITE_ALL` can delete **any** relationship in the database by supplying an arbitrary `relId`. The path param `{id}` is not validated as the owner of `relId` anywhere in the spec. **Fix — add an ownership check in `RelationshipService.deleteRelationship()`:** ```java PersonRelationship rel = relationshipRepository.findById(relId) .orElseThrow(() -> DomainException.notFound(ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship: " + relId)); boolean isPrincipal = rel.getPerson().getId().equals(personId) || rel.getRelatedPerson().getId().equals(personId); if (!isPrincipal) { throw DomainException.forbidden("Relationship " + relId + " does not belong to person " + personId); } ``` Symmetric types (SIBLING_OF, SPOUSE_OF) can be accessed from either person's page, so checking both sides is correct. **Test to accompany this fix (red first):** ```java @Test void delete_returns403_when_relId_belongs_to_different_person() { UUID unrelatedPersonId = UUID.randomUUID(); // relId belongs to personA/personB, call with unrelatedPersonId assertThatThrownBy(() -> relationshipService.deleteRelationship(unrelatedPersonId, relId)) .isInstanceOf(DomainException.class) .extracting("httpStatus").isEqualTo(HttpStatus.FORBIDDEN); } ``` --- ### 🚫 Blocker 2 — Circular PARENT_OF not prevented by schema The `unique_rel` constraint allows `(A, B, PARENT_OF)` **and** `(B, A, PARENT_OF)` to coexist. The DB sees two distinct rows. The BFS has `MAX_DEPTH = 8` which prevents an infinite loop, but the graph is now corrupt: A is B's parent AND B is A's parent. **Fix — check for the reverse edge in `RelationshipService.addRelationship()` before persisting:** ```java if (type == RelationType.PARENT_OF) { boolean reverseExists = relationshipRepository .existsByPersonIdAndRelatedPersonIdAndRelationType( request.relatedPersonId(), personId, RelationType.PARENT_OF); if (reverseExists) { throw DomainException.conflict( ErrorCode.CIRCULAR_RELATIONSHIP, "Cannot add PARENT_OF: reverse edge already exists"); } } ``` This is application-layer enforcement. A DB partial unique index for this case would require normalising asymmetric pairs — overkill. Service-layer check is the right call. Add `CIRCULAR_RELATIONSHIP` to `ErrorCode.java` and mirror it in `errors.ts`. --- ### 💡 Suggestion 3 — `notes` field — unbounded TEXT The spec defines `notes TEXT` with no length cap. A user with WRITE_ALL can store arbitrarily large content. Recommend: ```sql -- in V54: notes VARCHAR(2000) ``` ```java // CreateRelationshipRequest @Size(max = 2000) String notes ``` 2,000 characters covers any realistic relationship annotation in a historical family archive. --- ### 💡 Suggestion 4 — Never log `notes` content at INFO level The `notes` field contains personal/historical data. Ensure `RelationshipService` never logs this field's content at INFO/DEBUG level. Structured logging with parameterised SLF4J is sufficient: ```java logger.info("Relationship created: {} --{}-- {}", personId, type, relatedPersonId); // not: logger.info("Relationship created: {}", rel); // would include notes via toString() ``` Check that `PersonRelationship`'s Lombok `@Data`-generated `toString()` doesn't reach log output (it will include `notes`). Either exclude the field from toString or suppress it in `@ToString(exclude = "notes")`. --- ### What's correct - Permission model: `@RequirePermission(WRITE_ALL)` on all mutating endpoints. `READ_ALL` implied on reads. ✅ - `RelationType` as enum (Felix decision 1) — typo-safe, validated at controller boundary. ✅ - `no_self_rel` CHECK constraint at DB layer. ✅ - `MAX_DEPTH = 8` bounds the BFS. ✅ - `ON DELETE CASCADE` handles person deletion cleanly — no orphaned relationship rows. ✅
Author
Owner

🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

Felix's commitment to 18 BFS unit tests covers the inference engine well — that's the right safety net for the highest-risk class of bug (Markus item 4). The gap is everywhere else: no integration test plan for the persistence layer, no E2E test plan for the four UI surfaces, and several error paths have no corresponding acceptance criteria.


🚫 Blocker 1 — No integration test plan for RelationshipService

The service has non-trivial, constraint-backed logic that Mockito unit tests cannot verify: symmetric uniqueness, cascade deletion, the circular PARENT_OF check (Nora blocker 2), and the IDOR ownership check (Nora blocker 1). These must be covered by Testcontainers integration tests.

Minimum RelationshipServiceIntegrationTest coverage:

Test name What it verifies
addRelationship_stores_and_is_readable Happy path roundtrip
addRelationship_throws_409_when_duplicate unique_rel constraint firing gracefully
addRelationship_throws_409_when_circular_parent Nora blocker 2
deleteRelationship_throws_403_when_rel_belongs_to_different_person Nora blocker 1
deleteRelationship_succeeds_for_symmetric_type_from_either_side SIBLING_OF deletable from A or B's page
setFamilyMember_true_makes_person_appear_in_network Full network query integration
delete_person_cascades_to_relationships ON DELETE CASCADE — no orphan rows

Use @Transactional on each test method for automatic rollback — no @AfterEach cleanup.


🚫 Blocker 2 — No E2E test plan

The spec defines AC in text but no Playwright test plan. Three critical journeys need automated coverage before the ticket closes:

Journey Minimum Playwright assertion
Add PARENT_OF on edit page → Stammbaum shows node Navigate /persons/{id}/edit → add relationship → navigate /stammbaum → node present in SVG
Document badge renders Document with family-member sender + receiver + stored relationship → badge pill visible
Empty Stammbaum No family members → empty state text + link to /persons navigates correctly

These are thin happy-path E2E tests, not permutation coverage. The relationship CRUD permutations belong at the integration layer.


🚫 Blocker 3 — Year validation error path has no AC or test

Leonie decision 6 specifies "Bis before Von → blocking inline error." The AC section contains no corresponding criterion, and Felix's review doesn't mention a test for it.

Add to AC:

Given Von: 1935, Bis: 1920 is submitted in the add-relationship form, then the form shows "Bis-Jahr muss nach Von-Jahr liegen" below the Bis field and does not submit.

This must be covered by either a Vitest component test (year validation logic as $derived) or a Playwright test on the edit page.


💡 Suggestion 4 — Concurrent symmetric edge creation → DataIntegrityViolationException must map to 409

The partial unique index on SIBLING_OF can be violated by two concurrent requests that both pass the application-layer check before either inserts. Spring Data will throw DataIntegrityViolationException. If the service doesn't catch it, the user gets a 500.

Service catch:

try {
    return relationshipRepository.save(rel);
} catch (DataIntegrityViolationException e) {
    throw DomainException.conflict(ErrorCode.DUPLICATE_RELATIONSHIP, "Relationship already exists");
}

Add one integration test with a @Repeat(10) or explicit two-thread setup to verify the 409 response, not a 500.


💡 Suggestion 5 — Empty state on "Beziehungen" card (person detail page)

The spec adds a "Beziehungen" card to /persons/{id} but doesn't define the empty state. Add AC:

Given a person with no direct and no derived relationships, the Beziehungen card shows "Keine Beziehungen bekannt" rather than an empty list.

Add a Playwright test: navigate to a newly-created person → Beziehungen card shows the empty state copy.


What's correct

  • 18 BFS unit tests (17 LABEL_MAP entries + no-path) before label lookup implementation
  • @Enumerated(EnumType.STRING) with controller-boundary validation catches invalid relationType at the API surface before persistence
  • @Transactional on RelationshipService write methods (expected, per existing convention)
  • The generational layout algorithm has no async side effects — synchronous SVG computation is straightforward to test with Vitest component tests if visual output is needed
## 🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** Felix's commitment to 18 BFS unit tests covers the inference engine well — that's the right safety net for the highest-risk class of bug (Markus item 4). The gap is everywhere else: no integration test plan for the persistence layer, no E2E test plan for the four UI surfaces, and several error paths have no corresponding acceptance criteria. --- ### 🚫 Blocker 1 — No integration test plan for `RelationshipService` The service has non-trivial, constraint-backed logic that Mockito unit tests cannot verify: symmetric uniqueness, cascade deletion, the circular PARENT_OF check (Nora blocker 2), and the IDOR ownership check (Nora blocker 1). These must be covered by Testcontainers integration tests. **Minimum `RelationshipServiceIntegrationTest` coverage:** | Test name | What it verifies | |---|---| | `addRelationship_stores_and_is_readable` | Happy path roundtrip | | `addRelationship_throws_409_when_duplicate` | `unique_rel` constraint firing gracefully | | `addRelationship_throws_409_when_circular_parent` | Nora blocker 2 | | `deleteRelationship_throws_403_when_rel_belongs_to_different_person` | Nora blocker 1 | | `deleteRelationship_succeeds_for_symmetric_type_from_either_side` | SIBLING_OF deletable from A or B's page | | `setFamilyMember_true_makes_person_appear_in_network` | Full network query integration | | `delete_person_cascades_to_relationships` | ON DELETE CASCADE — no orphan rows | Use `@Transactional` on each test method for automatic rollback — no `@AfterEach` cleanup. --- ### 🚫 Blocker 2 — No E2E test plan The spec defines AC in text but no Playwright test plan. Three critical journeys need automated coverage before the ticket closes: | Journey | Minimum Playwright assertion | |---|---| | Add PARENT_OF on edit page → Stammbaum shows node | Navigate `/persons/{id}/edit` → add relationship → navigate `/stammbaum` → node present in SVG | | Document badge renders | Document with family-member sender + receiver + stored relationship → badge pill visible | | Empty Stammbaum | No family members → empty state text + link to `/persons` navigates correctly | These are thin happy-path E2E tests, not permutation coverage. The relationship CRUD permutations belong at the integration layer. --- ### 🚫 Blocker 3 — Year validation error path has no AC or test Leonie decision 6 specifies "Bis before Von → blocking inline error." The AC section contains no corresponding criterion, and Felix's review doesn't mention a test for it. **Add to AC:** > *Given Von: 1935, Bis: 1920 is submitted in the add-relationship form, then the form shows "Bis-Jahr muss nach Von-Jahr liegen" below the Bis field and does not submit.* This must be covered by either a Vitest component test (year validation logic as `$derived`) or a Playwright test on the edit page. --- ### 💡 Suggestion 4 — Concurrent symmetric edge creation → `DataIntegrityViolationException` must map to 409 The partial unique index on SIBLING_OF can be violated by two concurrent requests that both pass the application-layer check before either inserts. Spring Data will throw `DataIntegrityViolationException`. If the service doesn't catch it, the user gets a 500. **Service catch:** ```java try { return relationshipRepository.save(rel); } catch (DataIntegrityViolationException e) { throw DomainException.conflict(ErrorCode.DUPLICATE_RELATIONSHIP, "Relationship already exists"); } ``` Add one integration test with a `@Repeat(10)` or explicit two-thread setup to verify the 409 response, not a 500. --- ### 💡 Suggestion 5 — Empty state on "Beziehungen" card (person detail page) The spec adds a "Beziehungen" card to `/persons/{id}` but doesn't define the empty state. Add AC: > *Given a person with no direct and no derived relationships, the Beziehungen card shows "Keine Beziehungen bekannt" rather than an empty list.* Add a Playwright test: navigate to a newly-created person → Beziehungen card shows the empty state copy. --- ### What's correct - 18 BFS unit tests (17 LABEL_MAP entries + no-path) before label lookup implementation ✅ - `@Enumerated(EnumType.STRING)` with controller-boundary validation catches invalid relationType at the API surface before persistence ✅ - `@Transactional` on `RelationshipService` write methods (expected, per existing convention) ✅ - The generational layout algorithm has no async side effects — synchronous SVG computation is straightforward to test with Vitest component tests if visual output is needed ✅
Author
Owner

📋 Elicit — Requirements Engineer & Business Analyst

Verdict: ⚠️ Approved with concerns

Strong spec. The data model, inference rules, and UI surfaces are well-specified. The open questions are resolved. Three requirement gaps need resolution before implementation starts; three minor items can be addressed during implementation.


🚫 Blocker 1 — Missing AC: self-relationship attempt

The DB constraint no_self_rel CHECK (person_id != related_person_id) prevents self-referential rows at the database layer. The spec does not define what the user sees when they select the current person in the PersonTypeahead and click Hinzufügen.

Proposed AC (add to the Relationship editor section):

Given I am on the edit page for person A and I select person A in the "Beziehung hinzufügen" PersonTypeahead and click Hinzufügen, then the form shows the validation error "Eine Person kann keine Beziehung zu sich selbst haben" and does not submit.

This should ideally be a frontend validation (disabled if selected person matches the page subject), not purely a server-side 400. Define which layer catches it.


🚫 Blocker 2 — Missing AC: duplicate relationship

The unique_rel constraint blocks (A, B, PARENT_OF) being stored twice. The spec does not define the user-facing message when this constraint fires.

Proposed AC:

Given person A already has a PARENT_OF relationship to person B, when a second PARENT_OF from A to B is submitted, then the form shows "Diese Beziehung existiert bereits" and does not submit.

Add DUPLICATE_RELATIONSHIP to ErrorCode.java, mirror in errors.ts, add translation keys to de.json/en.json/es.json.


🚫 Blocker 3 — Empty state undefined on "Beziehungen" card (person detail page)

The spec adds a "Beziehungen" card to /persons/{id} showing "Direct + top derived relationships." For most persons at launch, this card will be empty (no relationships stored yet). The spec does not define what renders in this state.

Proposed specification:
When a person has zero direct relationships AND zero derived relationships, the Beziehungen card shows a muted one-liner:

„Noch keine Beziehungen bekannt."

Same typographic treatment as the empty conversation list on the existing person detail page. No call-to-action in read mode; in edit mode, a link "→ Beziehung hinzufügen" scrolls to the edit card.


💡 Suggestion 4 — notes field length constraint

The spec defines notes TEXT (unbounded). This leaves the field uncapped in both DB and frontend. Suggest adding a cap (2,000 characters is reasonable for a relationship annotation) as a functional requirement, reflected in:

  • Migration: VARCHAR(2000) or a CHECK (length(notes) <= 2000) constraint
  • CreateRelationshipRequest: @Size(max = 2000)
  • Frontend: maxlength="2000" on the textarea with a remaining-character counter

💡 Suggestion 5 — AC: /briefwechsel backward-compatibility

The spec states the /briefwechsel route "stays accessible via direct URL." Add an explicit AC to prevent a regression:

Given I navigate to /briefwechsel directly while logged in, the page renders correctly — no 404, no redirect, no blank page.

This is a one-line Playwright smoke test and prevents the route from accidentally being deleted during the nav change.


💡 Suggestion 6 — Side panel lazy-load NFR

Comment 5174 confirms that document count/stats in the Stammbaum side panel are lazy-loaded (not part of the initial network response). The spec has no performance NFR for this panel load. Given the 60+ transcriber persona on a tablet, consider adding:

NFR-PERF-04: Side panel data (document count, derived relationships) loads and renders within 500 ms on a mid-range device after node activation.

Without this, the lazy-load implementation has no measurable acceptance bar.


What's well-specified

  • Data model: correct normalisation, appropriate cardinality, all relation types defined
  • BFS inference: path abstraction contract is unusually precise for a spec (Markus item 4 works through it in detail)
  • All three open questions resolved before implementation
  • Non-functional requirements table covers performance, accessibility, responsive, security, i18n
  • Out-of-scope section is explicit (GEDCOM, gender-specific labels, drag-to-create)
  • Nav change preserves /briefwechsel accessibility
## 📋 Elicit — Requirements Engineer & Business Analyst **Verdict: ⚠️ Approved with concerns** Strong spec. The data model, inference rules, and UI surfaces are well-specified. The open questions are resolved. Three requirement gaps need resolution before implementation starts; three minor items can be addressed during implementation. --- ### 🚫 Blocker 1 — Missing AC: self-relationship attempt The DB constraint `no_self_rel CHECK (person_id != related_person_id)` prevents self-referential rows at the database layer. The spec does not define what the user sees when they select the current person in the PersonTypeahead and click Hinzufügen. **Proposed AC (add to the Relationship editor section):** > *Given I am on the edit page for person A and I select person A in the "Beziehung hinzufügen" PersonTypeahead and click Hinzufügen, then the form shows the validation error "Eine Person kann keine Beziehung zu sich selbst haben" and does not submit.* This should ideally be a frontend validation (disabled if selected person matches the page subject), not purely a server-side 400. Define which layer catches it. --- ### 🚫 Blocker 2 — Missing AC: duplicate relationship The `unique_rel` constraint blocks `(A, B, PARENT_OF)` being stored twice. The spec does not define the user-facing message when this constraint fires. **Proposed AC:** > *Given person A already has a PARENT_OF relationship to person B, when a second PARENT_OF from A to B is submitted, then the form shows "Diese Beziehung existiert bereits" and does not submit.* Add `DUPLICATE_RELATIONSHIP` to `ErrorCode.java`, mirror in `errors.ts`, add translation keys to `de.json`/`en.json`/`es.json`. --- ### 🚫 Blocker 3 — Empty state undefined on "Beziehungen" card (person detail page) The spec adds a "Beziehungen" card to `/persons/{id}` showing "Direct + top derived relationships." For most persons at launch, this card will be empty (no relationships stored yet). The spec does not define what renders in this state. **Proposed specification:** When a person has zero direct relationships AND zero derived relationships, the Beziehungen card shows a muted one-liner: > *„Noch keine Beziehungen bekannt."* Same typographic treatment as the empty conversation list on the existing person detail page. No call-to-action in read mode; in edit mode, a link "→ Beziehung hinzufügen" scrolls to the edit card. --- ### 💡 Suggestion 4 — `notes` field length constraint The spec defines `notes TEXT` (unbounded). This leaves the field uncapped in both DB and frontend. Suggest adding a cap (2,000 characters is reasonable for a relationship annotation) as a functional requirement, reflected in: - Migration: `VARCHAR(2000)` or a `CHECK (length(notes) <= 2000)` constraint - `CreateRelationshipRequest`: `@Size(max = 2000)` - Frontend: `maxlength="2000"` on the textarea with a remaining-character counter --- ### 💡 Suggestion 5 — AC: `/briefwechsel` backward-compatibility The spec states the `/briefwechsel` route "stays accessible via direct URL." Add an explicit AC to prevent a regression: > *Given I navigate to `/briefwechsel` directly while logged in, the page renders correctly — no 404, no redirect, no blank page.* This is a one-line Playwright smoke test and prevents the route from accidentally being deleted during the nav change. --- ### 💡 Suggestion 6 — Side panel lazy-load NFR Comment 5174 confirms that document count/stats in the Stammbaum side panel are lazy-loaded (not part of the initial network response). The spec has no performance NFR for this panel load. Given the 60+ transcriber persona on a tablet, consider adding: > *NFR-PERF-04: Side panel data (document count, derived relationships) loads and renders within 500 ms on a mid-range device after node activation.* Without this, the lazy-load implementation has no measurable acceptance bar. --- ### What's well-specified - Data model: correct normalisation, appropriate cardinality, all relation types defined ✅ - BFS inference: path abstraction contract is unusually precise for a spec (Markus item 4 works through it in detail) ✅ - All three open questions resolved before implementation ✅ - Non-functional requirements table covers performance, accessibility, responsive, security, i18n ✅ - Out-of-scope section is explicit (GEDCOM, gender-specific labels, drag-to-create) ✅ - Nav change preserves `/briefwechsel` accessibility ✅
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#358