feat: Stammbaum — family relationship graph replaces Briefwechsel in nav #358
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem & Motivation
Three converging user signals:
Feature Overview
Replace
/briefwechselin the nav with/stammbaum. Four interlocking parts:family_memberflag +person_relationshipstable/stammbaumfamily_member, add/remove direct relationshipsData Model
Migration:
V54__add_family_network.sqlRelationship types
Only direct, first-degree relationships are stored. All others are derived at query time.
Family (stored):
PARENT_OFSPOUSE_OFSIBLING_OFSocial (stored):
FRIENDCOLLEAGUEEMPLOYERDOCTORNEIGHBOROTHERnotesfor detailsDerived (never stored): grandparent, grandchild, uncle/aunt, niece/nephew, cousin, great-grandparent, in-laws — computed by
RelationshipInferenceService.Directionality rule:
PARENT_OFalways 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:
PersonRelationshipNew DTOs
API endpoints
RelationshipInferenceServiceBFS on an in-memory graph of all
PARENT_OF,SPOUSE_OF, andSIBLING_OFedges among family members. Graph has at most ~30 nodes — single-millisecond traversal.Sibling derivation: Two persons sharing a
PARENT_OFedge to the same parent are treated as siblings by BFS — noSIBLING_OFrow needed. ExplicitSIBLING_OFis 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.
Visual conventions:
Side panel on node click:
PersonStatsDTO)Mode B — Social Network (secondary, nice-to-have)
Toggle pill switches to D3-Force layout with all ~180 persons.
d3-force(~8 kb gzipped); Svelte renders the SVGTop bar
Pan/zoom: native SVG
viewBoxmanipulation on drag + wheel. No external library.Relationship Editor (Person Edit Page)
New card on
/persons/{id}/edit:PATCH /api/persons/{id}/family-member[pill: type] [person name] [year range] [✕ delete]; delete callsDELETE[Dropdown: relation type] [PersonTypeahead] [+ Hinzufügen]→POSTGET /api/persons/{id}/inferred-relationshipsDocument Detail Page — Relationship Badge
Trigger: Both sender and receiver have
family_member = true.The
+page.server.tscallsGET /api/persons/{senderId}/relationship-to/{receiverId}. If 404: no badge, no error.Person Detail Page Changes
/briefwechsel?senderId={current}&receiverId={other}Nav Change
AppNav.svelte: replace/briefwechselwith/stammbaum, new Paraglide keynav_stammbauminde.json/en.json/es.json./briefwechselroute is not deleted — stays accessible via direct URL, linked from person detail page.Non-Functional Requirements
RelationshipInferenceService.infer()< 50 ms (graph ≤ 30 nodes)GET /api/network?family=true< 200 msrole="button",aria-label="{name}, {birth}–{death}"WRITE_ALL. Reading:READ_ALLOut of Scope
Open Questions
COLLEAGUEof receiver)?Acceptance Criteria
Stammbaum page:
PARENT_OFandSPOUSE_OFrelationships exist, when I navigate to/stammbaum, then I see a generational SVG tree with at least 2 generations rendered correctly/persons/{id}Relationship badge:
Relationship editor:
PARENT_OFbetween Heinrich and Karl, thenGET /api/persons/{karl}/relationship-to/{heinrichsSibling}returns the inferred Onkel/Tante labelNav:
/briefwechseldirectly, the page still worksOQ-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)
[UP][DOWN][SPOUSE][SIBLING][UP, UP][UP, DOWN][UP, SIBLING][SIBLING, DOWN][UP, SPOUSE][SIBLING, SPOUSE][UP, UP, UP][UP, UP, SIBLING][UP, UP, DOWN, DOWN][UP, UP, UP, DOWN, DOWN][UP, UP, DOWN, DOWN, DOWN][UP, UP, UP, DOWN, DOWN, DOWN]MAX_DEPTH = 8stays (covers 3rd cousins for traversal; only labels differ beyond 6 hops).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.
🏛️ 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 —
relationshipis a first-class domain packageDecision: All relationship artifacts live in
org.raddatz.familienarchiv.relationship— not insideperson.This includes:
PersonRelationshipentityPersonRelationshipRepositoryRelationshipServiceRelationshipInferenceServiceRelationshipDTO,CreateRelationshipRequest,NetworkDTO,InferredRelationshipDTO,InferredRelationshipWithPersonDTOPersonServicecontains zero relationship logic. Cross-domain calls follow the established pattern:RelationshipServicecallsPersonServiceto resolve person data; never the reverse.2. Endpoint ownership —
RelationshipControllerowns everythingDecision: A new
RelationshipControllerin therelationshippackage owns all relationship endpoints:PersonControllergains zero new methods. This keepsPersonControllerclean and the relationship domain self-contained.3. Symmetric uniqueness, divorce, and former spouses
Decision A — Partial unique index applies to
SIBLING_OFonly:SPOUSE_OFis excluded from the partial index because the same two people could have married, divorced, and remarried — two separate rows with differentfrom_year/to_yearranges.SPOUSE_OFuniqueness (no duplicate active marriages) is enforced at the application layer inRelationshipService.Decision B — Former marriages use
to_year; no schema change needed:A
SPOUSE_OFrow with a non-nullto_yearis a former marriage. NoEX_SPOUSE_OFtype, nostatusfield. Display implications:"Ehegatte (1920–1935)"vs"Ehegatte (1938–)"SPOUSE_OFrows whereto_year IS NOT NULL; solid connector for current marriagesDecision C — Inference is time-ignorant:
RelationshipInferenceServiceloads all edges unconditionally regardless offrom_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_MAPin 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:relation_typePARENT_OFDOWNPARENT_OFUPSPOUSE_OFSPOUSESIBLING_OFSIBLINGThe BFS graph must be built as a bidirectional adjacency structure: for every
PARENT_OF(A, B)row, add two entries —A →DOWN→ BandB →UP→ A. For symmetric types, add both directions with the same token.The
LABEL_MAPkey is the orderedList<String>of abstract tokens along the path from person A to person B. Example: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.
NetworkDTOnodes — identity onlyDecision:
NetworkDTO.nodescarries identity-only fields. No document counts, no transcription stats.If
PersonSummaryDTOcarries additional fields (stats, aliases), the network endpoint uses this lighterPersonNodeDTOprojection instead. The< 200 msrequirement forGET /api/networkdepends on this — no aggregation queries on the node fetch.6. BFS loading — two queries maximum, no lazy traversal
Decision: Every call to
RelationshipInferenceServiceand every response fromGET /api/networkis backed by at most two queries:All
@ManyToOneassociations onPersonRelationshipareFetchType.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.
UI Specs — committed in
33ca2df4Three standalone HTML spec files have been added to
docs/specs/. Open in any browser to review.stammbaum-tree-spec.htmlThe
/stammbaumpage — generational SVG tree (Gen I–III), side panel with direkte/abgeleitete Beziehungen, and the inline "Beziehung hinzufügen" form. Covers:stammbaum-doc-badge-spec.htmlThe 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.
stammbaum-person-edit-spec.htmlThe new "Stammbaum & Beziehungen" card on
/persons/{id}/edit:Design decisions reflected in specs
🎨 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:
aria-expanded="true/false"to communicate panel state to assistive technology3. 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
/stammbaumDecision: When no person has
family_member = true, the SVG area shows a centered empty state: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.jsonnaming convention. The add-relationship form dropdown groups family types first, then social types, with a visual separator.PARENT_OFrelation_parent_ofSPOUSE_OFrelation_spouse_ofSIBLING_OFrelation_sibling_ofFRIENDrelation_friendCOLLEAGUErelation_colleagueEMPLOYERrelation_employerDOCTORrelation_doctorNEIGHBORrelation_neighborOTHERrelation_otherInferred/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, andes.jsonat implementation time.6. Year field validation UX (Von/Bis Jahr in add-relationship form)
Decision:
"Bis-Jahr muss nach Von-Jahr liegen". Blocks submit.Visual treatment: inline error text in
text-red-700below the field, paired witharia-describedbylinking 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.
🧑💻 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 StringDecision: Define
RelationTypeas a Java enum in therelationshippackage. Use@Enumerated(EnumType.STRING)on thePersonRelationshipentity field. ValidateCreateRelationshipRequest.relationTypeat the controller boundary (catchIllegalArgumentExceptionfromRelationType.valueOf(), throwResponseStatusException(BAD_REQUEST)).Rationale: nine known values, used in switch-style BFS token mapping — a
Stringfield allows invalid rows to silently reach the inference engine.2.
PATCH /family-member— cross-domain call viaPersonServiceDecision: Add
PersonService.setFamilyMember(UUID personId, boolean familyMember).RelationshipServicecalls it;RelationshipControllercallsRelationshipService.RelationshipControllernever touchesPersonRepositorydirectly — consistent with Markus's boundary rules and the existingDocumentService → PersonServicepattern.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:
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 bothprefers-color-scheme: darkanddata-theme='dark'. SVG elements in the Stammbaum page must usevar(--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:
V54__add_family_network.sqlRelationTypeenum +PersonRelationshipentityPersonRelationshipRepository+PersonService.setFamilyMember()RelationshipService+RelationshipInferenceService(18 BFS tests first, red → green)RelationshipControllernpm run generate:api)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.
🔧 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:
V54__add_family_network.sql. A version gap causes Flyway to halt on startup in production. Checkbackend/src/main/resources/db/migration/before naming the file.ON DELETE CASCADEon both FK columns ✅. Deleting a person automatically removes all their relationship rows. No orphan cleanup job needed.idx_person_rel_persononperson_idandidx_person_rel_relatedonrelated_person_idcover 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.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: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 —
TEXTunboundedFrom a storage perspective, unlimited
TEXTon 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.
🔐 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_ALLfor mutations,READ_ALLimplied 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_ALLcan delete any relationship in the database by supplying an arbitraryrelId. The path param{id}is not validated as the owner ofrelIdanywhere in the spec.Fix — add an ownership check in
RelationshipService.deleteRelationship():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):
🚫 Blocker 2 — Circular PARENT_OF not prevented by schema
The
unique_relconstraint allows(A, B, PARENT_OF)and(B, A, PARENT_OF)to coexist. The DB sees two distinct rows. The BFS hasMAX_DEPTH = 8which 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: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_RELATIONSHIPtoErrorCode.javaand mirror it inerrors.ts.💡 Suggestion 3 —
notesfield — unbounded TEXTThe spec defines
notes TEXTwith no length cap. A user with WRITE_ALL can store arbitrarily large content. Recommend:2,000 characters covers any realistic relationship annotation in a historical family archive.
💡 Suggestion 4 — Never log
notescontent at INFO levelThe
notesfield contains personal/historical data. EnsureRelationshipServicenever logs this field's content at INFO/DEBUG level. Structured logging with parameterised SLF4J is sufficient:Check that
PersonRelationship's Lombok@Data-generatedtoString()doesn't reach log output (it will includenotes). Either exclude the field from toString or suppress it in@ToString(exclude = "notes").What's correct
@RequirePermission(WRITE_ALL)on all mutating endpoints.READ_ALLimplied on reads. ✅RelationTypeas enum (Felix decision 1) — typo-safe, validated at controller boundary. ✅no_self_relCHECK constraint at DB layer. ✅MAX_DEPTH = 8bounds the BFS. ✅ON DELETE CASCADEhandles person deletion cleanly — no orphaned relationship rows. ✅🧪 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
RelationshipServiceThe 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
RelationshipServiceIntegrationTestcoverage:addRelationship_stores_and_is_readableaddRelationship_throws_409_when_duplicateunique_relconstraint firing gracefullyaddRelationship_throws_409_when_circular_parentdeleteRelationship_throws_403_when_rel_belongs_to_different_persondeleteRelationship_succeeds_for_symmetric_type_from_either_sidesetFamilyMember_true_makes_person_appear_in_networkdelete_person_cascades_to_relationshipsUse
@Transactionalon each test method for automatic rollback — no@AfterEachcleanup.🚫 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:
/persons/{id}/edit→ add relationship → navigate/stammbaum→ node present in SVG/personsnavigates correctlyThese 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:
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 →
DataIntegrityViolationExceptionmust map to 409The 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:
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:Add a Playwright test: navigate to a newly-created person → Beziehungen card shows the empty state copy.
What's correct
@Enumerated(EnumType.STRING)with controller-boundary validation catches invalid relationType at the API surface before persistence ✅@TransactionalonRelationshipServicewrite methods (expected, per existing convention) ✅📋 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):
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_relconstraint blocks(A, B, PARENT_OF)being stored twice. The spec does not define the user-facing message when this constraint fires.Proposed AC:
Add
DUPLICATE_RELATIONSHIPtoErrorCode.java, mirror inerrors.ts, add translation keys tode.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:
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 —
notesfield length constraintThe 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:VARCHAR(2000)or aCHECK (length(notes) <= 2000)constraintCreateRelationshipRequest:@Size(max = 2000)maxlength="2000"on the textarea with a remaining-character counter💡 Suggestion 5 — AC:
/briefwechselbackward-compatibilityThe spec states the
/briefwechselroute "stays accessible via direct URL." Add an explicit AC to prevent a regression: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:
Without this, the lazy-load implementation has no measurable acceptance bar.
What's well-specified
/briefwechselaccessibility ✅marcel referenced this issue2026-04-28 11:03:26 +02:00
marcel referenced this issue2026-04-28 11:12:16 +02:00