94 Commits

Author SHA1 Message Date
justin 6d3147e86d Merge pull request 'Fix #169: keep citation links on GEDCOM export' (#232) from fix-gedcom-citation-export into main
build-backend / build (push) Successful in 29s
2026-06-09 12:37:04 -04:00
justin b4434cb5dd Fix #169: keep citation links on GEDCOM export
Export emitted SOUR records but never the per-fact SOUR links, so a
Provenance→Provenance round-trip destroyed the sources graph (citations were
dropped). Emit citation links on the facts they sit on:
- person-level → 1 SOUR @Sx@ (2 PAGE)
- name-level   → 2 SOUR under 1 NAME
- event-level  → 2 SOUR under the event (incl. partnership events in FAM)
- relationship → 1 SOUR under FAM
Citations whose source didn't export are skipped.

Test: a person + event citation round-trips through export→import into a fresh
tree with their pages intact. GEDCOM suite 6 passed.

Closes #169

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 12:37:03 -04:00
justin 39e3eac3df Merge pull request 'Security: gate sessions on verified email (opt-in)' (#53) from security-require-email-verification into main
build-backend / build (push) Successful in 35s
2026-06-09 11:22:55 -04:00
justin 660fe7b37f Security: gate sessions on verified email (opt-in)
Backlog §2.10: registration issued a live session and email_verified_at was
written but never read, so an unverified user had full access and there was no
switch to require verification.

Add REQUIRE_EMAIL_VERIFICATION (default false). When true:
- resolve_session_user returns None for a user whose email_verified_at is null —
  the single read-side gate covering every authenticated request, incl. the
  session minted at registration.
- login raises 403 ("email not verified") instead of issuing a useless token.

Default false on purpose: self-hosts without SMTP, and accounts created before
this gate existed (email_verified_at null), must not be locked out. Operators
enable it once mail works and accounts are verified. Documented in .env.example.

Tests: default-off keeps unverified accounts working; on → register's session
won't resolve (401), login is 403, and after verify-email both work. 75 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 11:22:54 -04:00
justin 5485dd2077 Merge pull request 'Cleanup: infer a missing sex from a known-sex spouse (preview → approve)' (#52) from cleanup-sex-from-spouse into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m28s
2026-06-09 10:59:10 -04:00
justin 05d2773e25 Cleanup: infer a missing sex from a known-sex spouse (preview → approve)
Unset sex renders blue (male-colored), which is misleading next to a confirmed
male partner. Add a Cleanup action that proposes the opposite sex for an unset
partner of someone whose sex is set (couples are opposite-sex in practice — a
confirmed-male husband ⇒ a female wife). People whose known partners disagree
are skipped as ambiguous.

It's a preview the user reviews and approves in the Cleanup tool (reusing the
existing gender apply path + audit) — not an autonomous write. Backend:
guess_gender_by_spouse + GET /cleanup/gender/from-spouse. Frontend: an "Infer
from spouse" button feeding the existing proposal list. Test covers
propose-opposite, skip-no-partner, skip-already-set, apply, and re-preview.

Full suite 73 passed; frontend build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 10:59:08 -04:00
justin 768c68cbe0 Merge pull request 'Public tree view: add generation depth controls (shared with member view)' (#51) from public-tree-depth-controls into main
build-frontend / build (push) Successful in 1m29s
2026-06-09 10:35:44 -04:00
justin 7d6fbce87e Public tree view: add generation depth controls (shared with member view)
The public tree chart was fixed at 3 ancestors / 2 descendants. Add the same
Generations controls the member view has (slider + number stepper + "All" per
direction), applied live around the focused person.

Extracts the member page's inline DepthControl into a shared
components/depth-control.tsx and uses it in both, so they stay in sync. The
public chart gains anc/prog depth state + an apply effect (setAncestryDepth/
setProgenyDepth + updateTree) mirroring the member behavior.

tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 10:35:43 -04:00
justin 12ba0a0fb6 Merge pull request 'Public tree view: full-width canvas like the member view' (#50) from public-tree-fullwidth into main
build-frontend / build (push) Successful in 1m29s
2026-06-09 10:29:20 -04:00
justin 150d69e5ac Public tree view: full-width canvas like the member view
The public layout forced max-w-5xl on every /p page, so the tree chart was
cramped. Mirror the member shell: the public layout now drops the max-width for
the tree page (/p/<id>) only, giving the chart the full canvas (74vh to match
the member view), while the page keeps its heading and people list in a
centered max-w-5xl column. Person detail (/p/<id>/persons/<pid>) and /explore
stay narrow.

tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 10:29:18 -04:00
justin 053ce357ac Merge pull request 'Public view: add tree chart + homepage Explore links' (#49) from public-tree-chart-and-explore-link into main
build-frontend / build (push) Successful in 1m25s
2026-06-09 09:44:24 -04:00
justin 269cae556f Public view: add tree chart + homepage Explore links
Two gaps from review of the public surface:
- The public tree page showed only a list of names. Add the family-chart
  hourglass (PublicTreeChart) above the directory — the same renderer the
  member tree view uses, including the cycle-sanitisation that guards against a
  bad graph, fed by redacted public data. Click a card to recenter; "Open"
  links to the person's public page. Centers on the tree's home person.
- The homepage had no path to /explore. Add an "Explore" nav link and an
  "Explore public trees" hero button.

tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:44:23 -04:00
justin 0df44e7e59 Merge pull request 'Visibility phase 5: public /explore directory + search' (#48) from visibility-phase5-explore into main
build-frontend / build (push) Successful in 1m28s
2026-06-09 09:34:21 -04:00
justin 7a5c5f2882 Visibility phase 5: public /explore directory + search
A no-login directory of shared trees, backed by GET /api/v1/public/trees:
- /explore: searchable grid of public trees; debounced name search. Because the
  backend adds `site_members` trees when a valid session is present, signed-in
  users see more with no client-side branching.
- PublicHeader extracted and shared by /p and /explore (logo, Explore, Sign in).
- "Explore" entry added to the authed sidebar.

tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:34:20 -04:00
justin 20c7fbd8d6 Merge pull request 'Visibility phase 4: no-login public viewer pages + robots' (#47) from visibility-phase4-public-pages into main
build-frontend / build (push) Successful in 1m28s
2026-06-09 09:31:58 -04:00
justin b8405ced07 Visibility phase 4: no-login public viewer pages + robots
Adds the public viewing surface in the UI — shareable, no-login pages backed by
the redaction-safe /api/v1/public API:

- /p/[treeId]: tree name + searchable people directory (living people show as
  "Living person"; counts; links to person pages).
- /p/[treeId]/persons/[personId]: person detail — events, alternate names, and
  parents/partners/children as links to other public person pages.
- app/p/layout.tsx: slim public header (logo + Sign in), no app sidebar.
- robots.ts: allow /p/, disallow the authenticated app sections.
- Trees list: a "Public page ↗" link on every non-private tree so the owner can
  grab the shareable URL.

Client-rendered (same-origin fetch via Caddy). Follow-up (needs a frontend
SSR→backend base URL + a compose/env deploy step, so not auto-applied by
Watchtower): true server-rendering for SEO, a dynamic sitemap of public trees,
and per-page noindex for unlisted/site_members.

tsc clean; next build passes (both routes dynamic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:31:56 -04:00
justin 91a7ce1dc2 Merge pull request 'Fix leak: redact per-person on authed non-member reads' (#46) from fix-authed-nonmember-redaction into main
build-backend / build (push) Successful in 31s
2026-06-09 09:26:54 -04:00
justin 8b91326481 Fix leak: redact per-person on authed non-member reads
A logged-in NON-member of a public/unlisted tree could read living people's
dates, real alternate names, and media (incl. downloading photos) through the
family-view endpoints — only the person LIST was redacted; list_events,
list_relationships, list_names, list_media gated on can_view_tree alone.

For non-members, these now delegate to the same visibility-filtered reads the
public surface uses (person_visibility-driven): living-person events/names
dropped, relationships touching a hidden person dropped, media limited to
full-visibility persons, and media download (get_media → media_content) 404s
for a redacted/unlinked person's media. Members are unchanged.

Adds list_public_relationships_for_person / list_public_media / can_view_media
to public_view_service. Test: an authed non-member sees no living-person PII
across events/names/relationships/media and can't download a living person's
file, while the owner still sees everything. Full suite: 72 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:26:53 -04:00
justin 671b560768 Merge pull request 'docs: add product backlog (genealogy feature gap analysis)' (#45) from add-product-backlog into main 2026-06-09 09:19:22 -04:00
justin 6a5ef4d392 docs: add product backlog (genealogy feature gap analysis)
Output of a multi-agent gap analysis comparing Provenance against commercial
(Ancestry/MyHeritage/FamilySearch) and open-source (GRAMPS/Gramps Web/webtrees)
genealogy software: 15 research lenses, 580 raw features deduped into a 17-
category taxonomy, 302 features assessed against the codebase (have/partial/
planned/missing) with statuses verified against the actual code.

Includes an executive summary, per-category backlog with status/importance/
effort/phase, a quick-wins shortlist, and strategic differentiators. Statuses
reflect the repo at analysis time (before tree-visibility phases 1-3); a couple
of flagged items (e.g. the site_members tier) are already closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:19:21 -04:00
justin 3810b65de0 Merge pull request 'Visibility phase 3: redaction-safe public read API + leak test' (#44) from visibility-phase3-public-api into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m25s
2026-06-09 09:17:55 -04:00
justin 9820a77d25 Visibility phase 3: redaction-safe public read API + leak test
Adds the anonymous read surface (/api/v1/public) — the privacy-critical core.

- CurrentUserOrNone dependency: optional auth that never 401s (anonymous OK).
- public_view_service: every projection passes through privacy.person_visibility.
  persons redacted (living → "Living person", hidden dropped); relationships
  only when both endpoints non-hidden; events only for FULL-visibility persons
  (partnership events only when both partners full); names only for FULL
  persons. Not-viewable trees raise 404 (not 403) so the surface can't probe
  for private trees. Media deferred (higher-sensitivity; own pass later).
- public router: read-only directory + tree + persons/relationships/events +
  person detail/names/events. Directory lists `public` to all and adds
  `site_members` for authenticated callers; never lists unlisted/private.
- PublicTreeRead omits owner_id.

Tests (ran locally — CI does not run pytest): an anonymous end-to-end leak test
asserting a living person's real name, alias, and birth year appear in NO public
response while the deceased person's data does; plus private=404, unlisted
viewable-by-link-but-unlisted, site_members requires login, and directory
visibility. Full suite: 70 passed. Regenerated openapi.json + TS client.

Note: the AUTHED list endpoints still leak per-person for non-members
(pre-existing) — fixed next, separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:17:41 -04:00
justin 3ff03b037b Merge pull request 'Visibility phase 2: privacy-engine branches on viewer auth state' (#43) from visibility-phase2-privacy into main
build-backend / build (push) Successful in 29s
2026-06-09 09:08:17 -04:00
justin 84a743f5b9 Visibility phase 2: privacy-engine branches on viewer auth state
can_view_tree() now distinguishes anonymous vs authenticated non-members so the
four-level model is enforceable:
- public / unlisted → anyone, including anonymous (unlisted gated only by the
  link, so the API must never *list* it)
- site_members → any authenticated account (denies anonymous)
- private → members only
Members (any role) always view; soft-deleted trees stay hidden from everyone.
person_visibility (living-person redaction) is unchanged.

Tests: a full can_view_tree matrix across {anonymous, logged-in non-member,
member} × {public, unlisted, site_members, private}, plus deleted-tree-hidden
and the site_members anon-vs-logged-in case. Adds `engine`/`db_session` fixtures
(refactored out of `client`) so the engine can be unit-tested directly,
including the anonymous path that has no HTTP endpoint yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:08:04 -04:00
justin e6dfe39e84 Merge pull request 'Visibility phase 1: add site_members value + 4-option dropdown' (#42) from visibility-phase1-enum into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m26s
2026-06-09 09:02:01 -04:00
justin 4a3fe983fa Visibility phase 1: add site_members value + 4-option dropdown
First step of the public-viewing feature (design: docs/design/tree-visibility.md).
No non-member behavior change yet — this only widens the vocabulary and UI.

- TreeVisibility gains `site_members` (any authenticated user of the instance),
  giving the four-level model: public / site_members / unlisted / private.
- Alembic migration adds the enum value via an autocommit block (ALTER TYPE
  ADD VALUE can't run in a transaction on older Postgres); downgrade is a no-op
  since PG can't drop an enum value.
- Regenerated openapi.json + frontend TS client.
- Trees-list dropdown now offers Private / Public – Members / Unlisted / Public
  with an explanatory tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 08:54:45 -04:00
justin 251652a935 Merge pull request 'Trees list: inline visibility selector (private/unlisted/public)' (#41) from tree-visibility-control into main
build-frontend / build (push) Successful in 1m30s
2026-06-09 08:39:42 -04:00
justin dc1b6aac01 Trees list: inline visibility selector (private/unlisted/public)
Tree visibility was set to private with no UI to change it — the trees list
only displayed the value as text. Add a private/unlisted/public dropdown on
each tree card that PATCHes visibility immediately (optimistic), pulled out of
the card's navigation Link so it doesn't trigger a page change. Honors the
"everything configurable / full CRUD in the UI" invariants. Living people stay
protected by the privacy engine regardless of tree visibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 08:36:20 -04:00
justin f93327f5d3 Merge pull request 'Tree view: configurable generation depth (ancestors/descendants + All)' (#40) from configurable-tree-depth into main
build-frontend / build (push) Successful in 1m27s
2026-06-08 22:21:04 -04:00
justin c86771034c Tree view: configurable generation depth (ancestors/descendants + All)
Depth was hardcoded (3 ancestors, 2 descendants). Add a controls row to set
each direction independently — a slider plus a number stepper, with an "All"
toggle per direction — applied around whoever is currently focused.

- ancestor/descendant depth held in state; effective value is a large cap
  when "All" is on (the chart only renders people that exist, so the cap is
  free).
- changes apply to the live chart via setAncestryDepth/setProgenyDepth +
  updateTree without a full rebuild.
- fan mode (ancestors only) takes the ancestor depth via its `generations`
  prop, capped at 8 to avoid the radial layout's 2^n blow-up; its descendants
  control is disabled with a note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-08 22:20:07 -04:00
justin b51b65de80 Merge pull request 'Person page: one-click sex setter (no edit mode)' (#39) from quick-set-sex into main
build-frontend / build (push) Successful in 1m25s
2026-06-08 22:03:32 -04:00
justin 93c22b4bcf Person page: one-click sex setter (no edit mode)
Setting a person's sex meant clicking Edit, opening a dropdown, and saving.
Replace the read-only ♂/♀ symbol next to the name with an always-visible
two-button segmented control that PATCHes immediately on click (gender-only;
backend PATCH is exclude_unset so the name/other fields are untouched).
Clicking the active sex clears it. The full edit form still offers gender for
completeness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-08 21:42:59 -04:00
justin 7255920135 Merge pull request 'Person page: make marriage-event spouse picker searchable' (#38) from searchable-marriage-spouse into main
build-frontend / build (push) Successful in 1m27s
2026-06-08 21:30:48 -04:00
justin 62513ee22e Person page: make marriage-event spouse picker searchable
Adding a marriage/partnership event used a plain <select> for the spouse,
which is unusable on a large tree — you can't search, only scroll. Swap it
for the existing PersonCombobox (already used by the relationship form), which
filters by name as you type. No onCreate, so it still resolves to an existing
person id, which is what the partnership-event handler requires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-08 21:29:27 -04:00
justin ac0b9818dd Merge pull request 'Tree view: center a person between multiple spouses' (#37) from center-spouse-layout into main
build-frontend / build (push) Successful in 1m26s
2026-06-08 19:56:59 -04:00
justin 182a5dab16 Tree view: center a person between multiple spouses
family-chart 0.9.0 stacks all of a person's spouses on one gender-determined
side, so someone with two spouses (e.g. a woman with two husbands) renders
with both spouses piled above/below her and ambiguous child lines.

Patch the library (via patch-package) so the person stays centered and their
spouses split to alternating sides — spouse 1 above, spouse 2 below, further
spouses farther out — and order each couple's children to match, so children
descend from between the correct pair without crossed lines:

- setupSpouses: keep the person centered; place spouses at alternating
  offsets and recenter the cluster on the person's slot.
- sortChildrenWithSpouses: order children by spouse order (gender-independent)
  to match the new spouse positions.

Adds patch-package + a postinstall hook, and COPY patches into the Dockerfile
deps stage so the patch applies during `npm ci` in CI. Verified the patch
re-applies on a clean install and the production build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-08 19:56:17 -04:00
justin 77b78410ff Merge pull request 'Tree view: add "Back to default person" recenter link' (#36) from add-default-person-link into main
build-frontend / build (push) Successful in 1m27s
2026-06-08 15:25:04 -04:00
justin fe1e0171ff Tree view: add "Back to default person" recenter link
Once you recenter the tree on someone, there was no quick way back to the
tree's home/default person. Add a header link (shown only when a home person
is set and you're not already on them) that recenters the chart on
home_person_id via the existing goTo() — works in landscape, portrait, and
fan modes. Labels with the home person's name for clarity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-08 15:15:39 -04:00
justin 9dbdae975a Merge pull request 'Preserve focused person across tree/people/detail navigation' (#35) from improve-tree-people-navigation into main
build-frontend / build (push) Successful in 1m27s
2026-06-08 15:07:10 -04:00
justin c5a2a7f0d4 Preserve focused person across tree/people/detail navigation
The Tree view, People (Family) view, and person detail page each tracked
the "current person" independently, so moving between them reset you to the
home person. The detail page's "← Back to tree" link also pointed at the
People view (not the Tree) and carried no person, so it always landed on the
default person.

Make the focused person a URL-encoded concept that travels across views:

- Tree and People views read ?focus=<id> on load and mirror the focused
  person back into the URL via router.replace (no history spam), so leaving
  and returning keeps you centered where you were. Bookmarks/shared links
  also resolve to the right person.
- "Open person" links carry ?from=tree | ?from=people.
- The detail page's back link is now origin-aware: "← Back to Tree" →
  /tree?focus=<id> or "← Back to People" → /?focus=<id>, returning you in
  place instead of to the home person.
- Add a "View in tree →" link on the detail page — the previously missing
  direct jump from a person to the tree re-rooted on them.
- person→person relationship links (and create-relative redirect) pass
  `from` through so click-chains keep their anchor.

Also gitignore *.tsbuildinfo (Next build artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-08 14:48:32 -04:00
justin 8c36785197 Merge pull request 'Prevent duplicate relationships; harden tree render against cycles' (#34) from prevent-duplicate-links into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m23s
2026-06-08 11:35:12 -04:00
justin fae1162ff8 Prevent duplicate relationships; harden tree render against bad graphs
Root cause of the blank Jung tree: a child double-linked to the same parent
(and, generally, any cycle) made family-chart recurse forever.

Backend (the real fix):
- create_relationship now rejects an equivalent existing edge → 409.
  parent_child is directional (parent→child); partnership/sibling match the
  pair in either order. So you can't link the same two people the same way
  twice. (GEDCOM import already deduped; manual creates didn't.)

Frontend (defense in depth so data can never blank the view):
- Tree view sanitizes the graph before rendering: dedupes parents/spouses,
  drops self-links, and greedily breaks ancestor cycles (a person can't be
  their own ancestor); children are derived from the kept edges. The render is
  wrapped in try/catch and shows a note instead of a blank canvas, telling you
  which conflicting links were skipped.
- Person page surfaces the 409 ("They're already linked that way.").

59 backend tests pass (incl. dup-rejection + reverse-parent-child allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:35:11 -04:00
justin 1025f86657 Merge pull request 'Cleanup: list people with no sex set + inline set' (#33) from cleanup-unset-sex into main
build-frontend / build (push) Successful in 1m25s
2026-06-08 10:43:10 -04:00
justin a53858f920 Cleanup: list people with no sex set + inline set
Adds a "People with no sex set" section to the Cleanup page — lists everyone
whose gender is still null with inline ♂ Male / ♀ Female buttons (and a link to
their page). Refreshes after the source-match and first-name guess passes, so
it's the manual mop-up for whatever those leave behind.

Frontend only (reuses person list + PATCH) — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:43:08 -04:00
justin 941f9827c1 Merge pull request 'Cleanup: best-guess sex from first name (offline dictionary)' (#32) from gender-name-guess into main
build-backend / build (push) Successful in 33s
build-frontend / build (push) Successful in 1m26s
2026-06-08 10:30:36 -04:00
justin 6ec852a23a Cleanup: best-guess sex from first name (offline dictionary)
A "Guess from first name" option in the Cleanup gender section: a bundled,
curated given-name -> sex dictionary (weighted English + German for the first
real tree) proposes sex for people who don't have it set. Deterministic, offline,
no model. Genuinely ambiguous names (Marion, Frances, Jordan, …) are excluded
from both sets so they're left for a human. Reuses the existing preview/apply
gender flow, so every guess is reviewed before saving.

No migration. 56 backend tests pass; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:30:35 -04:00
justin 7405ec762f Merge pull request 'Tree Cleanup tool: bulk deceased / gender-from-source / name fixes (preview-first)' (#31) from tree-cleanup into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m28s
2026-06-08 10:17:02 -04:00
justin aa62ca490e Tree Cleanup tool: bulk fixes with preview → approve
A new per-tree Cleanup page (and cleanup_service + endpoints), each fix
preview-first per the propose-then-approve rule:

- Mark deceased by birth year: lists people born ≤ a cutoff (default 1930) not
  already deceased; apply sets is_living=false for the ones you keep checked.
- Set sex from a source GEDCOM: upload the source .ged (it carries SEX); matches
  by name and proposes sex only where it's missing — far more accurate than
  guessing from first names. Review, then apply.
- Names that look broken: flags date-in-surname / date-in-given / no-surname /
  packed given names, with inline editable given+surname; fix the checked ones.

No migration (uses existing columns). 55 backend tests pass (preview+apply for
all three); frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:17:01 -04:00
justin 97f7a9e0ff Merge pull request 'Show a sex symbol after the name on the person page' (#30) from person-sex-symbol into main
build-frontend / build (push) Successful in 1m27s
2026-06-08 09:16:40 -04:00
justin cd4ccb4ac8 Show a sex symbol after the name on the person page
A blue ♂ (male) or pink ♀ (female) symbol now follows the person's name in the
detail header, using the same gender tints as the tree cards. Nothing shows when
sex is unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:16:38 -04:00
justin 6696015970 Merge pull request 'Full light/dark theme toggle; brand-aware connector lines' (#29) from light-dark-theme into main
build-frontend / build (push) Successful in 1m26s
2026-06-07 11:49:01 -04:00
justin e8839b15a0 Full light/dark theme toggle; brand-aware connector lines
- Theme is now class-based (.dark on <html>) with a System/Light/Dark toggle in
  the sidebar, persisted to localStorage and applied pre-paint by an inline
  script (no flash). Replaces the prefers-color-scheme-only behavior, so a phone
  on a light OS theme can still choose dark and vice versa.
- New brand-derived --line token (Ink at 55%): a dark line on the light paper,
  light on dark. The family-chart tree connectors had the library's default
  white stroke and were invisible in light mode — now they use --line, as do
  the pedigree brackets and the fan-chart sectors.
- Light/dark tokens use the exact brand palette (Ink/Muted flip; Bronze/Paper
  constant).

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:48:59 -04:00
justin 548e883d82 Merge pull request 'Discoverable Add Person + inline create-new when linking relatives' (#28) from create-person-ux into main
build-frontend / build (push) Successful in 1m27s
2026-06-07 11:30:16 -04:00
justin 37ac49767e Make creating a person obvious; inline "create new" when linking relatives
- Family view gets a prominent "+ Add person" button that creates a person and
  opens their page to fill in details (previously you could only add a person
  via the empty-state form or by linking from another person).
- The person page's relationship picker (PersonCombobox) now offers
  "+ Create '<typed name>'" when the person doesn't exist yet: it creates them,
  links them in the chosen role (parent/child/partner/sibling), and jumps to
  their new page to edit — no more create-then-go-back-and-link.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:30:14 -04:00
justin 9b04bcefba Merge pull request 'Account export / restore-into-new-tree / delete' (#27) from account-export-restore-delete into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m27s
2026-06-07 11:26:06 -04:00
justin e9b2436ce0 Account export / restore-into-new-tree / delete
New account_service + endpoints under /users/me:
- GET /me/export — zip of every owned tree (account.json + media blobs).
- POST /me/import — restore a backup into NEW trees (ids remapped, media
  re-uploaded); non-destructive, never touches existing data.
- DELETE /me — soft-delete the user, their owned trees, and revoke sessions;
  guarded by retyping the account email.

Settings page wires all three (export download, restore upload, delete with
typed-email confirmation). No migration — uses existing tables + soft-delete.

52 backend tests pass (export→restore round-trip + delete guards); frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:26:04 -04:00
justin 8903e480cf Merge pull request 'Link media to people (person page + media page)' (#26) from media-person-linking into main
build-frontend / build (push) Successful in 1m24s
2026-06-07 11:19:27 -04:00
justin d27cc5dddc Link media to people (person page + media page)
The Media model already carried person_id/event_id/source_id and the upload
route already accepted person_id — this surfaces it in the UI:

- Person page: a Media card lists media linked to that person, uploads new
  files already linked ("Upload & link"), links existing unlinked media, and
  unlinks.
- Media page: each item gets a person picker to link/unlink.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:19:25 -04:00
justin 943f459b91 Merge pull request 'Shared marriage events; deterministic parent ordering' (#25) from marriage-event-parent-order into main
build-frontend / build (push) Successful in 1m23s
2026-06-07 11:15:55 -04:00
justin 5106538934 Shared marriage events; deterministic parent ordering
- Partnership life events (marriage/divorce/engagement) now attach to the
  couple's relationship, not each person. The add-event form asks for the
  spouse, finds-or-creates the partnership, and writes ONE event on it — shown
  on both partners' pages ("· with <spouse>"), entered once. Event values
  (RELI/OCCU detail) now render too.
- Family-view pedigree orders parents deterministically (father on top, mother
  below, stable fallback when gender is unknown) instead of by which link was
  created first.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:15:54 -04:00
justin 2669543e56 Merge pull request 'Account menu + Settings (change password), per-tree home person, full-width tree' (#24) from account-settings-home-person into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m24s
2026-06-07 11:05:41 -04:00
justin 0262ed3d97 Account menu + Settings (change password); per-tree home person; full-width tree
- Sidebar bottom-left now shows the signed-in user; clicking opens a menu with
  Settings and Sign out. New /settings page: account info + change password
  (POST /auth/change-password, re-verifies current password). Export/restore/
  delete are stubbed there for the next pass.
- Per-tree default/home person: tree.home_person_id (migration) + TreeUpdate/
  Read; the tree and family views open focused on it; the person page gets a
  "Set as default" control and "Default person" badge. Cleared if that person
  is deleted. Complements the account-level "this is me" link.
- Tree visualization now fills the content area (AppShell drops the max-width
  column on the /tree route); other pages stay centered.
- Audit records are coerced JSON-safe (UUIDs/enums), so PATCHing UUID fields
  like home_person_id audits cleanly.

50 backend tests pass; migration up/down verified; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:05:04 -04:00
justin 9ee960c4ef Merge pull request 'Auto-apply migrations on deploy (entrypoint + one-shot service)' (#23) from deploy-auto-migrate into main
build-backend / build (push) Successful in 26s
2026-06-07 10:54:31 -04:00
justin 7f640649b9 Auto-apply migrations on deploy (entrypoint + one-shot service)
So a deploy never needs a manual `alembic upgrade head`:

- Backend image gains an entrypoint that runs `alembic upgrade head` before
  uvicorn when RUN_MIGRATIONS=1 (set on the backend service). This self-migrates
  even on a Watchtower in-place image swap, which doesn't re-run one-shot jobs.
- A one-shot `migrate` service covers the `docker compose up` path; backend and
  worker depend on it completing, which also serializes it with the backend
  entrypoint so alembic never runs concurrently. `upgrade head` is idempotent.

Activating this needs the updated compose on the host once (Watchtower only
swaps images, not the compose file / env). After that, migrations are automatic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:50:28 -04:00
justin a8929c2862 Merge pull request 'Global Import menu entry + mobile drawer nav' (#22) from nav-global-import-mobile into main
build-frontend / build (push) Successful in 1m29s
2026-06-07 10:41:12 -04:00
justin b90ba53a3f Merge pull request 'GEDCOM: duplicate-aware import + maiden/married + RELI/NOTE mapping' (#21) from gedcom-import-dedupe into main
build-frontend / build (push) Has been cancelled
build-backend / build (push) Successful in 30s
2026-06-07 10:41:08 -04:00
justin c4e9d69e00 Merge pull request 'Alternate names, self-person link, deletion integrity + dangling people' (#20) from names-deletion-self into main
build-backend / build (push) Has been cancelled
build-frontend / build (push) Has been cancelled
2026-06-07 10:41:02 -04:00
justin 0673896133 Merge pull request 'Tree search + click-rebuild; searchable relationship picker; gender dropdown' (#19) from tree-search-combobox into main
build-frontend / build (push) Has been cancelled
2026-06-07 10:40:59 -04:00
justin 1164841950 Global Import in the menu; mobile drawer nav
- Add a top-level "Import" entry to the sidebar and a global /import page, so
  you can start a tree from a GEDCOM without first creating an empty one. The
  import flow now picks its destination (new tree, or an existing one) — the
  tree-scoped page reuses the same <GedcomImport> with a fixed destination and
  keeps Export.
- Extract the sidebar chrome into <AppShell> and give small screens a working
  menu: a hamburger opens the full sidebar as a slide-in drawer (it was just a
  logo + "Trees" link before). Used by both /trees and /import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:40:01 -04:00
justin 5824e70895 GEDCOM: duplicate-aware import + typed name/attribute mapping
Duplicate detection (the "merge / skip / overwrite" the user asked for):
- New POST /gedcom/preview dry-runs the file and flags incoming people that
  resemble existing ones (name similarity via difflib + birth-year guard;
  high/medium score). No writes.
- /gedcom/import takes default_action (new|skip|merge|overwrite) + per-xref
  resolutions {xref: {action, target_id}}:
    new       create as a new person (current behavior)
    skip      link families to the existing person, copy nothing
    merge     attach the incoming names (as alternates), events, citations,
              and notes onto the existing person
    overwrite soft-delete the existing person, import the incoming one fresh
  Relationship creation is deduped so a merge can't double an edge.

Richer record mapping (covers the user's repo's GEDCOM):
- Multiple NAME records honor their TYPE; _MARNM (and NICK) import as typed
  alternate names — maiden stays primary, married becomes a "married" Name.
- RELI -> a "religion" event with the value in detail; OCCU/EDUC values too.
- NOTE -> person notes (and event notes); NOTE/RELI are no longer "unmapped".
- Export round-trips name TYPE.

Verified against the user's 2185-person export: 0 unmapped tags. 48 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:35:55 -04:00
justin 04ccdbf96a Alternate names (maiden/married), self-person link, deletion integrity
Names (the genealogy standard: maiden name primary, married/alias as typed
alternates):
- Name model already supported multiple typed names; expose full CRUD —
  NameCreate/Read/Update schemas, name_service (one-primary invariant,
  promote-on-delete), nested /persons/{id}/names routes.
- Person page gains a Names card: add/edit/delete + "make primary", with a
  curated name_type dropdown (birth/maiden, married, alias, nickname, …).

Self-person ("who am I"):
- users.self_person_id FK (use_alter for the users<->persons<->trees cycle)
  + migration; PATCH /users/me/self-person; "This is me" / "This is you"
  on the person page. Soft-deleting the linked person clears it.

Deletion integrity (fixes the broken tree view):
- delete_person now soft-deletes the relationships touching the person, so no
  dangling edges remain; family-chart also filters links to missing people.
- Optional cascade=true recursively deletes descendants (GEDCOM cleanup);
  the person page asks "only this person" vs "with all descendants".
- DELETE returns {deleted: n}.

Family view surfaces "Not connected to anyone" so dangling people aren't lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:21:12 -04:00
justin f165ccb941 Tree search + click-rebuild; searchable relationship picker; gender dropdown
- Tree page: add a "Find a person" search box that jumps the chart to a
  match and rebuilds the hourglass (parents/grandparents/partner/children)
  around them. Clicking any card recenters via family-chart's default
  behavior (setAncestryDepth 3 / setProgenyDepth 2), syncing focus through
  setAfterUpdate for the "Open profile" link.
- Person detail: replace the relationship "add" <select> with a
  type-to-filter PersonCombobox so long people lists are searchable.
- Person detail: gender is now a Male/Female dropdown, not free text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:58:45 -04:00
justin e0fb924a1d Merge pull request 'Full-CRUD sweep (API): update for tree/source/citation/relationship/media' (#18) from crud-sweep-backend into main
build-backend / build (push) Successful in 30s
2026-06-07 09:53:18 -04:00
justin cf5518c7ec Full-CRUD sweep: update endpoints for tree, source, citation, relationship, media
Closes the rule #8 gap at the API layer: PATCH endpoints + service updates for Tree (name/description/visibility), Source, Citation (page/detail/confidence), Relationship (qualifier/notes), and Media (title/attachment) — editor-gated and audited. Every core entity now has create/read/update/delete. Edit UIs for these land in the frontend batch. 37 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:53:17 -04:00
justin 26df03cfd7 Merge pull request 'Edit people + events; existing-person picker; full-CRUD rule' (#17) from crud-edits into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m25s
2026-06-07 09:35:56 -04:00
justin ab064bce6e Edit UI for people and life events; existing-person picker in family view
Person detail: an Edit form for name + gender + living status + privacy, and inline edit of each life event (type + structured date). Family view: the add-relative buttons now search existing people (link the real person) or create new — preventing duplicate spouses/parents — and adding a child to someone with one spouse links both parents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 76b7f453c1 Add update (CRUD) for events and people; record the full-CRUD invariant
Events and people are now editable, not write-once: PATCH /events/{id} (type, structured date, place, notes) and PATCH /persons/{id} (vitals, privacy, and the primary name's given/surname). CLAUDE.md gains rule #8: every stored object must support full CRUD in API and UI — historical research is constant correction. Tests cover both updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 438d2db2e7 Merge pull request 'Tree layout toggles + fan + card->profile + server search' (#16) from phase2-tree-toggles into main
build-frontend / build (push) Successful in 1m26s
2026-06-07 08:01:32 -04:00
justin 99913ada94 Tree layout toggles (landscape/portrait/fan), card->profile, server search
Tree page gets Landscape/Portrait/Fan toggles: landscape & portrait via family-chart's orientation; a hand-rolled radial Fan chart of ancestors (rings per generation, click to recenter). Clicking a card recenters and updates an 'Open <name> →' link to that person's profile. The People directory search now hits the server-side pg_trgm fuzzy endpoint (debounced) so it spans the whole tree, not just the loaded page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 08:01:31 -04:00
justin 584b323121 Merge pull request 'Fuzzy search (pg_trgm) + living-person protection' (#15) from phase2-search-privacy into main
build-backend / build (push) Successful in 30s
2026-06-07 07:55:14 -04:00
justin 4788ae7723 Add fuzzy name search (pg_trgm) and living-person protection
Fuzzy search: pg_trgm extension + trigram GIN indexes on name parts and a GET /trees/{id}/persons?q= search ranked by trigram similarity (finds Mueller for 'muller'), privacy-filtered. Living-person protection: the privacy engine now derives possibly-living status (explicit flag, else no death fact + birth within ~100y or unknown) and returns 'redacted' for non-members of public/unlisted trees; the service minimises those records ('Living person', no vitals). Members are unaffected. 31 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 07:55:13 -04:00
justin 51f0066e61 Merge pull request 'Interactive Tree view (pan/zoom genealogy chart)' (#14) from interactive-tree into main
build-frontend / build (push) Successful in 1m21s
2026-06-06 23:07:04 -04:00
justin bfa6c0782a Add an interactive Tree view (pan/zoom genealogy chart)
Researched how FamilySearch/Geni/MyHeritage lay out trees (switchable pedigree/portrait/fan, an interactive canvas with pan/zoom + click-to-recenter, gender colors, birth-death years) and built a real Tree page on the MIT d3 library family-chart instead of a flat list. Ancestors + descendants around a focus person, click any card to recenter, drag to pan, scroll to zoom — scales to large imported trees. Tree is now the first per-tree sidebar item and the default when opening a tree; People keeps the searchable directory + add/edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 23:07:02 -04:00
justin 2f21e767f3 Merge pull request 'Scalable people directory' (#13) from people-directory into main
build-frontend / build (push) Successful in 1m20s
2026-06-06 22:54:10 -04:00
justin f6bcf198ee Make the people index a scalable scrollable directory
A flat wrap of every person didn't scale to imported trees. Replace it with a bounded (max-height, scrollable) searchable directory: clean name + birth–death-year rows, focus highlight, a result count, and a 200-row cap with a 'refine your search' notice so a thousand-person tree stays fast and usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:54:08 -04:00
justin b13fafd624 Merge pull request 'Phase 2: GEDCOM import/export' (#12) from phase2-gedcom into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m22s
2026-06-06 22:46:50 -04:00
justin 631d050540 Add GEDCOM Import/Export UI (defaults to importing into a new tree)
An Import/Export page (sidebar) that defaults to importing into a NEW tree to avoid duplicating existing people, with an explicit 'append to this tree' option (warned), a mapping-report display (counts + skipped tags), and a one-click .ged export download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin d48029a407 Add GEDCOM import/export
A pragmatic GEDCOM parser + mapper: import reads INDI/FAM/SOUR and creates people, names, life events, partnership + qualified parent-child relationships, marriage events, places (deduped), sources, and citations from SOUR refs — returning a mapping report (counts + unmapped tags). Export serializes the tree back to GEDCOM (families derived from the edge model). Import is additive (no merge) and runs inline for now. Round-trip test passes; 29 tests total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin 18dea507d1 Merge pull request 'Pedigree connector lines + 4 grandparents' (#11) from pedigree-connectors into main
build-frontend / build (push) Successful in 1m22s
2026-06-06 22:32:12 -04:00
justin 99a660485e Pedigree: connector lines + correct 4-grandparent structure
Rebuilds the family view's pedigree as a recursive bracket chart with CSS connector lines — focus links to its two parents (2 lines), and each parent links to its two parents (4 lines to grandparents). Fixes the prior ambiguity where grandparent slots weren't tied to a specific parent: now every parent shows its own two parent slots, so a person clearly has up to four grandparents grouped by lineage. Height-robust connectors (each leaf draws its own spine half + stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:32:10 -04:00
justin cf6dcf9ce2 Merge pull request 'Family view + soft-delete/recovery' (#10) from phase1-familyview into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m18s
2026-06-06 22:19:02 -04:00
justin 22bc536978 Rebuild People as a family view (pedigree + family group); add recovery UI
The People page is no longer a flat list: it's a focus-person family view with a pedigree of ancestors (parents + grandparents), a spouse/partner panel, and a children panel — with inline 'add parent/child/spouse' (creates the person + the relationship), click-to-refocus, birth–death years, and a searchable people index. Modeled on how real genealogy tools center on a person and let you walk the graph.

Adds delete/restore UI: a Delete on the person page, per-tree delete + a 'Recently deleted' restore section on the trees list, and a Recovery page (sidebar) for deleted people.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin f2205b93f4 Add soft-delete + recovery and tree-wide graph endpoints
Tree and person soft-delete + restore (owner-only for trees, editor for people) with recovery listings (?deleted=true); the worker already purges past the 30-day window. Adds tree-wide GET /relationships and /events so the family/pedigree view loads the whole graph in a few calls. 27 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin b0c7c8570b Merge pull request 'App-shell UI overhaul + media stream endpoint' (#9) from ui-shell into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m20s
2026-06-06 21:56:26 -04:00
117 changed files with 18096 additions and 269 deletions
+1
View File
@@ -19,6 +19,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact. 5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe). 6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys. 7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
8. **Full CRUD on every object.** Every stored entity (person, name, event, relationship, source, citation, media, tree, …) must support create, read, **update**, and delete — in the API *and* the UI. Historical research is constant correction and new information, so nothing is write-once. Any new feature or data type ships with all four operations; an entity you can create but not edit is a bug.
## Tech stack ## Tech stack
+4
View File
@@ -21,7 +21,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY app ./app COPY app ./app
COPY alembic.ini ./alembic.ini COPY alembic.ini ./alembic.ini
COPY migrations ./migrations COPY migrations ./migrations
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
EXPOSE 8000 EXPOSE 8000
# The entrypoint runs migrations first when RUN_MIGRATIONS=1, then the command.
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+12
View File
@@ -40,6 +40,18 @@ async def get_current_user(request: Request, session: SessionDep) -> User:
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
async def get_current_user_or_none(request: Request, session: SessionDep) -> User | None:
"""Optional auth for public read endpoints — never raises. Returns the user
when a valid session is present, else None (anonymous viewer)."""
raw_token = extract_session_token(request)
if raw_token is None:
return None
return await auth_service.resolve_session_user(session, raw_token=raw_token)
CurrentUserOrNone = Annotated[User | None, Depends(get_current_user_or_none)]
def get_mailer() -> Mailer: def get_mailer() -> Mailer:
settings = get_settings() settings = get_settings()
if settings.mailer == "smtp" and settings.smtp_host: if settings.mailer == "smtp" and settings.smtp_host:
+8
View File
@@ -5,9 +5,13 @@ from fastapi import APIRouter
from app.api.v1 import ( from app.api.v1 import (
auth, auth,
citations, citations,
cleanup,
events, events,
gedcom,
media, media,
names,
persons, persons,
public,
relationships, relationships,
sources, sources,
trees, trees,
@@ -19,8 +23,12 @@ api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(trees.router) api_router.include_router(trees.router)
api_router.include_router(persons.router) api_router.include_router(persons.router)
api_router.include_router(names.router)
api_router.include_router(events.router) api_router.include_router(events.router)
api_router.include_router(relationships.router) api_router.include_router(relationships.router)
api_router.include_router(sources.router) api_router.include_router(sources.router)
api_router.include_router(citations.router) api_router.include_router(citations.router)
api_router.include_router(media.router) api_router.include_router(media.router)
api_router.include_router(gedcom.router)
api_router.include_router(cleanup.router)
api_router.include_router(public.router)
+14 -1
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, HTTPException, Request, Response, status
from app.api.deps import MailerDep, SessionDep, extract_session_token from app.api.deps import CurrentUser, MailerDep, SessionDep, extract_session_token
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas.auth import ( from app.schemas.auth import (
LoginRequest, LoginRequest,
PasswordChange,
PasswordResetConfirm, PasswordResetConfirm,
PasswordResetRequest, PasswordResetRequest,
RegisterRequest, RegisterRequest,
@@ -79,3 +80,15 @@ async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> Non
await auth_service.reset_password( await auth_service.reset_password(
session, raw_token=data.token, new_password=data.new_password session, raw_token=data.token, new_password=data.new_password
) )
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
data: PasswordChange, session: SessionDep, current: CurrentUser
) -> None:
await auth_service.change_password(
session,
user=current,
current_password=data.current_password,
new_password=data.new_password,
)
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead from app.schemas.source import CitationCreate, CitationRead, CitationUpdate
from app.services import citation_service, tree_service from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"]) router = APIRouter(prefix="/trees", tags=["citations"])
@@ -31,6 +31,25 @@ async def list_citations(
return [CitationRead.model_validate(c) for c in citations] return [CitationRead.model_validate(c) for c in citations]
@router.patch("/{tree_id}/citations/{citation_id}", response_model=CitationRead)
async def update_citation(
tree_id: uuid.UUID,
citation_id: uuid.UUID,
data: CitationUpdate,
session: SessionDep,
current: CurrentUser,
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.update_citation(
session,
actor=current,
tree=tree,
citation_id=citation_id,
changes=data.model_dump(exclude_unset=True),
)
return CitationRead.model_validate(citation)
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation( async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
+111
View File
@@ -0,0 +1,111 @@
import uuid
from fastapi import APIRouter, File, UploadFile
from app.api.deps import CurrentUser, SessionDep
from app.schemas.cleanup import (
CleanupResult,
DeceasedApply,
DeceasedCandidate,
GenderApply,
GenderProposal,
NameApply,
NameIssue,
)
from app.services import cleanup_service, tree_service
router = APIRouter(prefix="/trees", tags=["cleanup"])
@router.get("/{tree_id}/cleanup/deceased", response_model=list[DeceasedCandidate])
async def preview_deceased(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
born_on_or_before: int = 1930,
) -> list[DeceasedCandidate]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.preview_deceased(
session, actor=current, tree=tree, year=born_on_or_before
)
return [DeceasedCandidate(**r) for r in rows]
@router.post("/{tree_id}/cleanup/deceased", response_model=CleanupResult)
async def apply_deceased(
tree_id: uuid.UUID, data: DeceasedApply, session: SessionDep, current: CurrentUser
) -> CleanupResult:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
n = await cleanup_service.apply_deceased(
session, actor=current, tree=tree, person_ids=data.person_ids
)
return CleanupResult(updated=n)
@router.post("/{tree_id}/cleanup/gender/preview", response_model=list[GenderProposal])
async def preview_gender(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
file: UploadFile = File(...),
) -> list[GenderProposal]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = (await file.read()).decode("utf-8", errors="replace")
rows = await cleanup_service.preview_gender(
session, actor=current, tree=tree, gedcom_text=text
)
return [GenderProposal(**r) for r in rows]
@router.get("/{tree_id}/cleanup/gender/guess", response_model=list[GenderProposal])
async def guess_gender(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[GenderProposal]:
"""Best-guess sex from first names (bundled dictionary) for people missing it."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.guess_gender_by_name(session, actor=current, tree=tree)
return [GenderProposal(**r) for r in rows]
@router.get("/{tree_id}/cleanup/gender/from-spouse", response_model=list[GenderProposal])
async def guess_gender_from_spouse(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[GenderProposal]:
"""Infer a missing sex from a partner whose sex is set (opposite-sex couple)."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.guess_gender_by_spouse(session, actor=current, tree=tree)
return [GenderProposal(**r) for r in rows]
@router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
async def apply_gender(
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
) -> CleanupResult:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
n = await cleanup_service.apply_gender(
session,
actor=current,
tree=tree,
updates=[u.model_dump() for u in data.updates],
)
return CleanupResult(updated=n)
@router.get("/{tree_id}/cleanup/names", response_model=list[NameIssue])
async def preview_names(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[NameIssue]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.preview_names(session, actor=current, tree=tree)
return [NameIssue(**r) for r in rows]
@router.post("/{tree_id}/cleanup/names", response_model=CleanupResult)
async def apply_names(
tree_id: uuid.UUID, data: NameApply, session: SessionDep, current: CurrentUser
) -> CleanupResult:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
n = await cleanup_service.apply_names(
session, actor=current, tree=tree, edits=[e.model_dump() for e in data.edits]
)
return CleanupResult(updated=n)
+29 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.event import EventCreate, EventRead from app.schemas.event import EventCreate, EventRead, EventUpdate
from app.services import event_service, tree_service from app.services import event_service, tree_service
router = APIRouter(prefix="/trees", tags=["events"]) router = APIRouter(prefix="/trees", tags=["events"])
@@ -20,6 +20,15 @@ async def create_event(
return EventRead.model_validate(event) return EventRead.model_validate(event)
@router.get("/{tree_id}/events", response_model=list[EventRead])
async def list_tree_events(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[EventRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
events = await event_service.list_events(session, viewer_id=current.id, tree=tree)
return [EventRead.model_validate(e) for e in events]
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead]) @router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
async def list_person_events( async def list_person_events(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
@@ -31,6 +40,25 @@ async def list_person_events(
return [EventRead.model_validate(e) for e in events] return [EventRead.model_validate(e) for e in events]
@router.patch("/{tree_id}/events/{event_id}", response_model=EventRead)
async def update_event(
tree_id: uuid.UUID,
event_id: uuid.UUID,
data: EventUpdate,
session: SessionDep,
current: CurrentUser,
) -> EventRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
event = await event_service.update_event(
session,
actor=current,
tree=tree,
event_id=event_id,
changes=data.model_dump(exclude_unset=True),
)
return EventRead.model_validate(event)
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event( async def delete_event(
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
+68
View File
@@ -0,0 +1,68 @@
import json
import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile
from app.api.deps import CurrentUser, SessionDep
from app.schemas.gedcom import ImportPreview, ImportReport
from app.services import gedcom, tree_service
router = APIRouter(prefix="/trees", tags=["gedcom"])
@router.post("/{tree_id}/gedcom/preview", response_model=ImportPreview)
async def preview_gedcom(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
file: UploadFile = File(...),
) -> ImportPreview:
"""Dry run: report counts and incoming people that look like duplicates of
existing ones, so the user can choose how to resolve each before importing."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = (await file.read()).decode("utf-8", errors="replace")
report = await gedcom.preview_gedcom(session, actor=current, tree=tree, text=text)
return ImportPreview(**report)
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
async def import_gedcom(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
file: UploadFile = File(...),
default_action: str = Form("new"),
resolutions: str = Form("{}"),
) -> ImportReport:
"""Import a GEDCOM. ``default_action`` (new|skip|merge|overwrite) applies to
incoming people that match an existing one; ``resolutions`` is a JSON object
{xref: {action, target_id}} overriding it per record."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = (await file.read()).decode("utf-8", errors="replace")
try:
parsed = json.loads(resolutions or "{}")
except json.JSONDecodeError:
parsed = {}
report = await gedcom.import_gedcom(
session,
actor=current,
tree=tree,
text=text,
default_action=default_action,
resolutions=parsed,
)
return ImportReport(**report)
@router.get("/{tree_id}/gedcom/export")
async def export_gedcom(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = await gedcom.export_gedcom(session, viewer_id=current.id, tree=tree)
safe = "".join(c for c in tree.name if c.isalnum() or c in " -_").strip() or "tree"
return Response(
content=text,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{safe}.ged"'},
)
+21 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile, status from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead from app.schemas.media import MediaRead, MediaUpdate
from app.services import media_service, tree_service from app.services import media_service, tree_service
@@ -81,6 +81,26 @@ async def media_content(
) )
@router.patch("/{tree_id}/media/{media_id}", response_model=MediaRead)
async def update_media(
tree_id: uuid.UUID,
media_id: uuid.UUID,
data: MediaUpdate,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> MediaRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.update_media(
session,
actor=current,
tree=tree,
media_id=media_id,
changes=data.model_dump(exclude_unset=True),
)
return _read(media)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media( async def delete_media(
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
+90
View File
@@ -0,0 +1,90 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.name import NameCreate, NameRead, NameUpdate
from app.services import name_service, tree_service
# Names are nested under their person (which is nested under the tree tenant).
router = APIRouter(prefix="/trees", tags=["names"])
@router.get("/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
async def list_names(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[NameRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
names = await name_service.list_names(
session, viewer_id=current.id, tree=tree, person_id=person_id
)
return [NameRead.model_validate(n) for n in names]
@router.post(
"/{tree_id}/persons/{person_id}/names",
response_model=NameRead,
status_code=status.HTTP_201_CREATED,
)
async def create_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: NameCreate,
session: SessionDep,
current: CurrentUser,
) -> NameRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
name = await name_service.create_name(
session,
actor=current,
tree=tree,
person_id=person_id,
name_type=data.name_type,
given=data.given,
surname=data.surname,
prefix=data.prefix,
suffix=data.suffix,
nickname=data.nickname,
is_primary=data.is_primary,
)
return NameRead.model_validate(name)
@router.patch(
"/{tree_id}/persons/{person_id}/names/{name_id}", response_model=NameRead
)
async def update_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
name_id: uuid.UUID,
data: NameUpdate,
session: SessionDep,
current: CurrentUser,
) -> NameRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
name = await name_service.update_name(
session,
actor=current,
tree=tree,
person_id=person_id,
name_id=name_id,
changes=data.model_dump(exclude_unset=True),
)
return NameRead.model_validate(name)
@router.delete(
"/{tree_id}/persons/{person_id}/names/{name_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
name_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await name_service.delete_name(
session, actor=current, tree=tree, person_id=person_id, name_id=name_id
)
+63 -3
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
from app.services import person_service, tree_service from app.services import person_service, tree_service
# Persons are nested under their tree (the tenant boundary). # Persons are nested under their tree (the tenant boundary).
@@ -36,13 +36,73 @@ async def create_person(
@router.get("/{tree_id}/persons", response_model=list[PersonRead]) @router.get("/{tree_id}/persons", response_model=list[PersonRead])
async def list_persons( async def list_persons(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
deleted: bool = False,
q: str | None = None,
) -> list[PersonRead]: ) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) if q:
persons = await person_service.search_persons(
session, viewer_id=current.id, tree=tree, query=q
)
elif deleted:
persons = await person_service.list_deleted_persons(
session, viewer_id=current.id, tree=tree
)
else:
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
return [PersonRead.model_validate(p) for p in persons] return [PersonRead.model_validate(p) for p in persons]
@router.patch("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def update_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: PersonUpdate,
session: SessionDep,
current: CurrentUser,
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.update_person(
session,
actor=current,
tree=tree,
person_id=person_id,
changes=data.model_dump(exclude_unset=True),
)
return PersonRead.model_validate(person)
@router.delete("/{tree_id}/persons/{person_id}")
async def delete_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
cascade: bool = False,
) -> dict[str, int]:
"""Delete a person. ``cascade=true`` also deletes all descendants. Returns
the number of persons deleted (1 unless cascading)."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
deleted = await person_service.delete_person(
session, actor=current, tree=tree, person_id=person_id, cascade=cascade
)
return {"deleted": deleted}
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
async def restore_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.restore_person(
session, actor=current, tree=tree, person_id=person_id
)
return PersonRead.model_validate(person)
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead) @router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def get_person( async def get_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+135
View File
@@ -0,0 +1,135 @@
"""Public, read-only viewing surface.
Optional auth (anonymous allowed). Every response is built by
``public_view_service``, which routes through the privacy engine and redacts
possibly-living people. No create/update/delete here.
"""
import uuid
from fastapi import APIRouter
from app.api.deps import CurrentUserOrNone, SessionDep
from app.schemas.event import EventRead
from app.schemas.name import NameRead
from app.schemas.person import PersonRead
from app.schemas.relationship import RelationshipRead
from app.schemas.tree import PublicTreeRead
from app.services import public_view_service
router = APIRouter(prefix="/public", tags=["public"])
def _vid(viewer: CurrentUserOrNone) -> uuid.UUID | None:
return viewer.id if viewer else None
@router.get("/trees", response_model=list[PublicTreeRead])
async def public_directory(
session: SessionDep,
viewer: CurrentUserOrNone,
q: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[PublicTreeRead]:
trees = await public_view_service.list_public_trees(
session, viewer_id=_vid(viewer), q=q, limit=limit, offset=offset
)
return [PublicTreeRead.model_validate(t) for t in trees]
@router.get("/trees/{tree_id}", response_model=PublicTreeRead)
async def public_tree(
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
) -> PublicTreeRead:
tree = await public_view_service.get_public_tree(
session, viewer_id=_vid(viewer), tree_id=tree_id
)
return PublicTreeRead.model_validate(tree)
@router.get("/trees/{tree_id}/persons", response_model=list[PersonRead])
async def public_persons(
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
) -> list[PersonRead]:
tree = await public_view_service.get_public_tree(
session, viewer_id=_vid(viewer), tree_id=tree_id
)
persons = await public_view_service.list_public_persons(
session, viewer_id=_vid(viewer), tree=tree
)
return [PersonRead.model_validate(p) for p in persons]
@router.get("/trees/{tree_id}/relationships", response_model=list[RelationshipRead])
async def public_relationships(
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
) -> list[RelationshipRead]:
tree = await public_view_service.get_public_tree(
session, viewer_id=_vid(viewer), tree_id=tree_id
)
rels = await public_view_service.list_public_relationships(
session, viewer_id=_vid(viewer), tree=tree
)
return [RelationshipRead.model_validate(r) for r in rels]
@router.get("/trees/{tree_id}/events", response_model=list[EventRead])
async def public_events(
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
) -> list[EventRead]:
tree = await public_view_service.get_public_tree(
session, viewer_id=_vid(viewer), tree_id=tree_id
)
events = await public_view_service.list_public_events(
session, viewer_id=_vid(viewer), tree=tree
)
return [EventRead.model_validate(e) for e in events]
@router.get("/trees/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def public_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
session: SessionDep,
viewer: CurrentUserOrNone,
) -> PersonRead:
tree = await public_view_service.get_public_tree(
session, viewer_id=_vid(viewer), tree_id=tree_id
)
person = await public_view_service.get_public_person(
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
)
return PersonRead.model_validate(person)
@router.get("/trees/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
async def public_person_names(
tree_id: uuid.UUID,
person_id: uuid.UUID,
session: SessionDep,
viewer: CurrentUserOrNone,
) -> list[NameRead]:
tree = await public_view_service.get_public_tree(
session, viewer_id=_vid(viewer), tree_id=tree_id
)
names = await public_view_service.list_public_person_names(
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
)
return [NameRead.model_validate(n) for n in names]
@router.get("/trees/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
async def public_person_events(
tree_id: uuid.UUID,
person_id: uuid.UUID,
session: SessionDep,
viewer: CurrentUserOrNone,
) -> list[EventRead]:
tree = await public_view_service.get_public_tree(
session, viewer_id=_vid(viewer), tree_id=tree_id
)
events = await public_view_service.list_public_person_events(
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
)
return [EventRead.model_validate(e) for e in events]
+29 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.relationship import RelationshipCreate, RelationshipRead from app.schemas.relationship import RelationshipCreate, RelationshipRead, RelationshipUpdate
from app.services import relationship_service, tree_service from app.services import relationship_service, tree_service
router = APIRouter(prefix="/trees", tags=["relationships"]) router = APIRouter(prefix="/trees", tags=["relationships"])
@@ -24,6 +24,15 @@ async def create_relationship(
return RelationshipRead.model_validate(relationship) return RelationshipRead.model_validate(relationship)
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
async def list_relationships(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[RelationshipRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree)
return [RelationshipRead.model_validate(r) for r in rels]
@router.get( @router.get(
"/{tree_id}/persons/{person_id}/relationships", "/{tree_id}/persons/{person_id}/relationships",
response_model=list[RelationshipRead], response_model=list[RelationshipRead],
@@ -38,6 +47,25 @@ async def list_person_relationships(
return [RelationshipRead.model_validate(r) for r in rels] return [RelationshipRead.model_validate(r) for r in rels]
@router.patch("/{tree_id}/relationships/{relationship_id}", response_model=RelationshipRead)
async def update_relationship(
tree_id: uuid.UUID,
relationship_id: uuid.UUID,
data: RelationshipUpdate,
session: SessionDep,
current: CurrentUser,
) -> RelationshipRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rel = await relationship_service.update_relationship(
session,
actor=current,
tree=tree,
relationship_id=relationship_id,
changes=data.model_dump(exclude_unset=True),
)
return RelationshipRead.model_validate(rel)
@router.delete( @router.delete(
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT "/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
) )
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead from app.schemas.source import SourceCreate, SourceRead, SourceUpdate
from app.services import source_service, tree_service from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"]) router = APIRouter(prefix="/trees", tags=["sources"])
@@ -40,6 +40,25 @@ async def get_source(
return SourceRead.model_validate(source) return SourceRead.model_validate(source)
@router.patch("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def update_source(
tree_id: uuid.UUID,
source_id: uuid.UUID,
data: SourceUpdate,
session: SessionDep,
current: CurrentUser,
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.update_source(
session,
actor=current,
tree=tree,
source_id=source_id,
changes=data.model_dump(exclude_unset=True),
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source( async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
+29 -3
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.tree import TreeCreate, TreeRead from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate
from app.services import tree_service from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"]) router = APIRouter(prefix="/trees", tags=["trees"])
@@ -22,8 +22,13 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse
@router.get("", response_model=list[TreeRead]) @router.get("", response_model=list[TreeRead])
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]: async def list_my_trees(
trees = await tree_service.list_trees_for_user(session, user=current) session: SessionDep, current: CurrentUser, deleted: bool = False
) -> list[TreeRead]:
if deleted:
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
else:
trees = await tree_service.list_trees_for_user(session, user=current)
return [TreeRead.model_validate(t) for t in trees] return [TreeRead.model_validate(t) for t in trees]
@@ -31,3 +36,24 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
return TreeRead.model_validate(tree) return TreeRead.model_validate(tree)
@router.patch("/{tree_id}", response_model=TreeRead)
async def update_tree(
tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser
) -> TreeRead:
tree = await tree_service.update_tree(
session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True)
)
return TreeRead.model_validate(tree)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
@router.post("/{tree_id}/restore", response_model=TreeRead)
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
return TreeRead.model_validate(tree)
+49 -3
View File
@@ -1,7 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter, File, Form, Response, UploadFile
from app.api.deps import CurrentUser from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.user import UserRead from app.schemas.user import UserRead, UserSelfPersonUpdate
from app.services import account_service, user_service
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -9,3 +10,48 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
async def read_me(current: CurrentUser) -> UserRead: async def read_me(current: CurrentUser) -> UserRead:
return UserRead.model_validate(current) return UserRead.model_validate(current)
@router.patch("/me/self-person", response_model=UserRead)
async def set_self_person(
data: UserSelfPersonUpdate, session: SessionDep, current: CurrentUser
) -> UserRead:
"""Link (or unlink) the Person record that represents this account."""
user = await user_service.set_self_person(
session, user=current, person_id=data.self_person_id
)
return UserRead.model_validate(user)
@router.get("/me/export")
async def export_account(
session: SessionDep, current: CurrentUser, store: ObjectStoreDep
) -> Response:
"""Download a full backup (JSON + media) of every tree the user owns."""
data = await account_service.export_account(session, store, user=current)
return Response(
content=data,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="provenance-export.zip"'},
)
@router.post("/me/import")
async def import_account(
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
file: UploadFile = File(...),
) -> dict:
"""Restore a previously-exported backup into new trees (non-destructive)."""
raw = await file.read()
return await account_service.import_account(session, store, user=current, raw_zip=raw)
@router.delete("/me", status_code=204)
async def delete_account(
session: SessionDep, current: CurrentUser, confirm_email: str = Form(...)
) -> None:
"""Delete the account: the user, their owned trees, and their sessions.
Requires retyping the account email as a guard."""
await account_service.delete_account(session, user=current, confirm_email=confirm_email)
+5
View File
@@ -48,6 +48,11 @@ class Settings(BaseSettings):
purge_after_days: int = 30 # soft-deleted rows older than this are purged purge_after_days: int = 30 # soft-deleted rows older than this are purged
# --- Email (SMTP) --- # --- Email (SMTP) ---
# When true, a user with no verified email gets no active session (login is
# refused and existing sessions stop resolving). Default false so self-hosts
# without SMTP — and accounts created before this gate existed — aren't
# locked out; operators turn it on once mail works and accounts are verified.
require_email_verification: bool = False
mailer: str = Field(default="console", description="console | smtp") mailer: str = Field(default="console", description="console | smtp")
smtp_host: str | None = None smtp_host: str | None = None
smtp_port: int = 587 smtp_port: int = 587
+4 -3
View File
@@ -9,9 +9,10 @@ import enum
class TreeVisibility(enum.StrEnum): class TreeVisibility(enum.StrEnum):
public = "public" public = "public" # anyone on the web (anonymous), listed + search-indexable
unlisted = "unlisted" site_members = "site_members" # any authenticated user of this instance
private = "private" unlisted = "unlisted" # anyone with the link (anonymous), not listed/indexed
private = "private" # members only (default)
class MembershipRole(enum.StrEnum): class MembershipRole(enum.StrEnum):
+17 -1
View File
@@ -7,7 +7,7 @@ aliases) so name changes over time are first-class.
import uuid import uuid
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy import Enum as SAEnum from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -33,6 +33,22 @@ class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "names" __tablename__ = "names"
# Trigram indexes for fuzzy name search (Mueller/Müller/Muller). Requires the
# pg_trgm extension (enabled in the accompanying migration).
__table_args__ = (
Index(
"ix_names_given_trgm",
"given",
postgresql_using="gin",
postgresql_ops={"given": "gin_trgm_ops"},
),
Index(
"ix_names_surname_trgm",
"surname",
postgresql_using="gin",
postgresql_ops={"surname": "gin_trgm_ops"},
),
)
person_id: Mapped[uuid.UUID] = mapped_column( person_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True ForeignKey("persons.id", ondelete="CASCADE"), index=True
+10
View File
@@ -26,6 +26,16 @@ class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
default=TreeVisibility.private, default=TreeVisibility.private,
server_default=TreeVisibility.private.value, server_default=TreeVisibility.private.value,
) )
# The person a tree opens focused on (its "home"/root person). Cleared if
# that person is deleted. use_alter + name: trees<->persons form an FK cycle.
home_person_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey(
"persons.id",
ondelete="SET NULL",
name="fk_trees_home_person_id",
use_alter=True,
)
)
class TreeMembership(Base, UUIDPrimaryKey, Timestamps): class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
+14 -1
View File
@@ -3,9 +3,10 @@ multiple auth providers later (the provider-link table arrives with the auth
slice). ``hashed_password`` is nullable: external/OIDC users have none. slice). ``hashed_password`` is nullable: external/OIDC users have none.
""" """
import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -19,3 +20,15 @@ class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
hashed_password: Mapped[str | None] = mapped_column(String(255)) hashed_password: Mapped[str | None] = mapped_column(String(255))
# The Person record that *is* this user ("home person"). Cleared if that
# person is deleted, so the link can never dangle.
self_person_id: Mapped[uuid.UUID | None] = mapped_column(
# use_alter + explicit name: users<->persons<->trees form an FK cycle,
# so this constraint must be created/dropped via ALTER, not inline.
ForeignKey(
"persons.id",
ondelete="SET NULL",
name="fk_users_self_person_id",
use_alter=True,
)
)
+5
View File
@@ -29,6 +29,11 @@ class PasswordResetConfirm(BaseModel):
new_password: str = Field(min_length=8) new_password: str = Field(min_length=8)
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
class SessionRead(BaseModel): class SessionRead(BaseModel):
user: UserRead user: UserRead
token: str token: str
+50
View File
@@ -0,0 +1,50 @@
import uuid
from pydantic import BaseModel
class DeceasedCandidate(BaseModel):
person_id: uuid.UUID
name: str
birth_year: int
class DeceasedApply(BaseModel):
person_ids: list[uuid.UUID]
class GenderProposal(BaseModel):
person_id: uuid.UUID
name: str
proposed_gender: str
class GenderUpdate(BaseModel):
person_id: uuid.UUID
gender: str
class GenderApply(BaseModel):
updates: list[GenderUpdate]
class NameIssue(BaseModel):
name_id: uuid.UUID
person_id: uuid.UUID
given: str | None = None
surname: str | None = None
issue: str
class NameEdit(BaseModel):
name_id: uuid.UUID
given: str | None = None
surname: str | None = None
class NameApply(BaseModel):
edits: list[NameEdit]
class CleanupResult(BaseModel):
updated: int
+13
View File
@@ -20,6 +20,19 @@ class EventCreate(BaseModel):
notes: str | None = None notes: str | None = None
class EventUpdate(BaseModel):
# All optional; only fields explicitly sent are changed (PATCH semantics).
event_type: str | None = None
place_id: uuid.UUID | None = None
date_value: str | None = None
date_start: date | None = None
date_end: date | None = None
date_precision: str | None = None
calendar: str | None = None
detail: str | None = None
notes: str | None = None
class EventRead(BaseModel): class EventRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from pydantic import BaseModel
class ImportReport(BaseModel):
counts: dict[str, int]
unmapped_tags: list[str]
class DuplicateMatch(BaseModel):
# An incoming GEDCOM person that resembles an existing one in the tree.
xref: str
incoming_name: str
incoming_birth_year: str | None = None
existing_person_id: uuid.UUID
existing_name: str
existing_birth_year: str | None = None
score: str # "high" | "medium"
class ImportPreview(BaseModel):
counts: dict[str, int]
potential_duplicates: list[DuplicateMatch]
unmapped_tags: list[str]
+7
View File
@@ -4,6 +4,13 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
class MediaUpdate(BaseModel):
title: str | None = None
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
source_id: uuid.UUID | None = None
class MediaRead(BaseModel): class MediaRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class NameCreate(BaseModel):
# Open vocabulary: birth/maiden, married, alias, religious, nickname, ...
name_type: str = "birth"
given: str | None = None
surname: str | None = None
prefix: str | None = None
suffix: str | None = None
nickname: str | None = None
is_primary: bool = False
class NameUpdate(BaseModel):
name_type: str | None = None
given: str | None = None
surname: str | None = None
prefix: str | None = None
suffix: str | None = None
nickname: str | None = None
is_primary: bool | None = None
class NameRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
person_id: uuid.UUID
name_type: str
given: str | None
surname: str | None
prefix: str | None
suffix: str | None
nickname: str | None
is_primary: bool
sort_order: int
created_at: datetime
+10
View File
@@ -15,6 +15,16 @@ class PersonCreate(BaseModel):
notes: str | None = None notes: str | None = None
class PersonUpdate(BaseModel):
# Person fields + the primary name's parts; only sent fields are changed.
given: str | None = None
surname: str | None = None
gender: str | None = None
is_living: bool | None = None
privacy: PersonPrivacy | None = None
notes: str | None = None
class PersonRead(BaseModel): class PersonRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+5
View File
@@ -15,6 +15,11 @@ class RelationshipCreate(BaseModel):
notes: str | None = None notes: str | None = None
class RelationshipUpdate(BaseModel):
qualifier: ParentChildQualifier | None = None
notes: str | None = None
class RelationshipRead(BaseModel): class RelationshipRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+17
View File
@@ -33,6 +33,23 @@ class SourceRead(BaseModel):
created_at: datetime created_at: datetime
class SourceUpdate(BaseModel):
title: str | None = None
author: str | None = None
source_type: str | None = None
repository: str | None = None
url: str | None = None
citation_text: str | None = None
publication_info: str | None = None
quality_note: str | None = None
class CitationUpdate(BaseModel):
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationCreate(BaseModel): class CitationCreate(BaseModel):
source_id: uuid.UUID source_id: uuid.UUID
# Exactly one target fact. # Exactly one target fact.
+21
View File
@@ -12,6 +12,13 @@ class TreeCreate(BaseModel):
visibility: TreeVisibility = TreeVisibility.private visibility: TreeVisibility = TreeVisibility.private
class TreeUpdate(BaseModel):
name: str | None = None
description: str | None = None
visibility: TreeVisibility | None = None
home_person_id: uuid.UUID | None = None
class TreeRead(BaseModel): class TreeRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -20,4 +27,18 @@ class TreeRead(BaseModel):
description: str | None description: str | None
visibility: TreeVisibility visibility: TreeVisibility
owner_id: uuid.UUID owner_id: uuid.UUID
home_person_id: uuid.UUID | None = None
created_at: datetime created_at: datetime
class PublicTreeRead(BaseModel):
"""Tree projection for the public surface — deliberately omits owner_id so a
public/unlisted tree doesn't reveal which account owns it."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
visibility: TreeVisibility
home_person_id: uuid.UUID | None = None
+6
View File
@@ -19,4 +19,10 @@ class UserRead(BaseModel):
email: str email: str
display_name: str | None display_name: str | None
email_verified_at: datetime | None email_verified_at: datetime | None
self_person_id: uuid.UUID | None = None
created_at: datetime created_at: datetime
class UserSelfPersonUpdate(BaseModel):
# null clears the link; otherwise the Person that represents this account.
self_person_id: uuid.UUID | None = None
+352
View File
@@ -0,0 +1,352 @@
"""Account-level data portability: export the signed-in user's owned trees as a
zip (JSON + media bytes), restore such a zip into a brand-new tree
(non-destructive), and delete the account.
The export format is a zip containing ``account.json`` plus ``media/<id>`` blobs.
Restore always creates new trees and remaps ids, so it can't clobber existing
data.
"""
import hashlib
import io
import json
import uuid
import zipfile
from datetime import UTC, date, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.objectstore.base import ObjectStore
from app.models.auth import Session as SessionModel
from app.models.enums import MembershipRole
from app.models.event import Event
from app.models.media import Media
from app.models.person import Name, Person
from app.models.place import Place
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree, TreeMembership
from app.models.user import User
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
EXPORT_VERSION = 1
_DROP = {"created_at", "updated_at", "deleted_at", "tree_id"}
# Media columns rebuilt on import (storage is re-keyed, checksum recomputed).
_MEDIA_DROP = _DROP | {"uploader_id", "storage_key", "byte_size", "checksum_sha256"}
_DATE_FIELDS = {"date_start", "date_end"}
def _row(obj, drop: set[str]) -> dict:
out: dict = {}
for col in obj.__table__.columns.keys(): # noqa: SIM118
if col in drop:
continue
out[col] = getattr(obj, col)
return out
async def _entities(session: AsyncSession, model, tree_id: uuid.UUID):
stmt = select(model).where(model.tree_id == tree_id, model.deleted_at.is_(None))
return list((await session.execute(stmt)).scalars().all())
async def export_account(session: AsyncSession, store: ObjectStore, *, user: User) -> bytes:
"""Build a zip of every tree the user owns: account.json + media blobs."""
trees = list(
(
await session.execute(
select(Tree).where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
)
).scalars().all()
)
payload: dict = {
"version": EXPORT_VERSION,
"user": {"email": user.email, "display_name": user.display_name},
"trees": [],
}
media_blobs: list[tuple[str, bytes]] = []
for tree in trees:
media_rows = await _entities(session, Media, tree.id)
media_out = []
for m in media_rows:
ref = f"media/{m.id}"
rec = _row(m, _MEDIA_DROP)
rec["_file"] = ref
media_out.append(rec)
try:
media_blobs.append((ref, await store.get_object(key=m.storage_key)))
except Exception: # noqa: BLE001 — a missing blob shouldn't abort the export
rec["_file"] = None
payload["trees"].append({
"tree": {
"name": tree.name,
"description": tree.description,
"visibility": tree.visibility,
"home_person_id": tree.home_person_id,
},
"places": [_row(p, _DROP) for p in await _entities(session, Place, tree.id)],
"persons": [_row(p, _DROP) for p in await _entities(session, Person, tree.id)],
"names": [_row(n, _DROP) for n in await _entities(session, Name, tree.id)],
"relationships": [
_row(r, _DROP) for r in await _entities(session, Relationship, tree.id)
],
"events": [_row(e, _DROP) for e in await _entities(session, Event, tree.id)],
"sources": [_row(s, _DROP) for s in await _entities(session, Source, tree.id)],
"citations": [_row(c, _DROP) for c in await _entities(session, Citation, tree.id)],
"media": media_out,
})
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("account.json", json.dumps(payload, default=str, indent=2))
for ref, blob in media_blobs:
zf.writestr(ref, blob)
return buf.getvalue()
def _as_uuid(v) -> uuid.UUID | None:
return uuid.UUID(v) if v else None
def _as_date(v) -> date | None:
return date.fromisoformat(v) if v else None
async def import_account(
session: AsyncSession, store: ObjectStore, *, user: User, raw_zip: bytes
) -> dict:
"""Restore an exported zip into NEW trees owned by the user. Non-destructive:
every record gets a fresh id; nothing existing is touched."""
try:
zf = zipfile.ZipFile(io.BytesIO(raw_zip))
payload = json.loads(zf.read("account.json"))
except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as e:
raise NotFound("not a valid Provenance export") from e
counts: dict[str, int] = {"trees": 0, "persons": 0, "events": 0, "media": 0}
for tdata in payload.get("trees", []):
t = tdata.get("tree", {})
tree = Tree(
owner_id=user.id,
name=(t.get("name") or "Imported tree"),
description=t.get("description"),
visibility=t.get("visibility") or "private",
)
session.add(tree)
await session.flush()
session.add(
TreeMembership(tree_id=tree.id, user_id=user.id, role=MembershipRole.owner)
)
counts["trees"] += 1
# id remaps from the export's ids to the freshly created ones.
pmap: dict[str, uuid.UUID] = {}
rmap: dict[str, uuid.UUID] = {}
smap: dict[str, uuid.UUID] = {}
nmap: dict[str, uuid.UUID] = {}
emap: dict[str, uuid.UUID] = {}
plmap: dict[str, uuid.UUID] = {}
for pl in tdata.get("places", []):
obj = Place(
tree_id=tree.id,
name=pl.get("name") or "",
place_type=pl.get("place_type"),
latitude=pl.get("latitude"),
longitude=pl.get("longitude"),
)
session.add(obj)
await session.flush()
plmap[pl["id"]] = obj.id
for p in tdata.get("persons", []):
obj = Person(
tree_id=tree.id,
gender=p.get("gender"),
is_living=p.get("is_living"),
privacy=p.get("privacy") or "inherit",
notes=p.get("notes"),
)
session.add(obj)
await session.flush()
pmap[p["id"]] = obj.id
counts["persons"] += 1
for n in tdata.get("names", []):
pid = pmap.get(n.get("person_id"))
if pid is None:
continue
obj = Name(
tree_id=tree.id,
person_id=pid,
name_type=n.get("name_type") or "birth",
given=n.get("given"),
surname=n.get("surname"),
prefix=n.get("prefix"),
suffix=n.get("suffix"),
nickname=n.get("nickname"),
display_name=n.get("display_name"),
is_primary=bool(n.get("is_primary")),
sort_order=n.get("sort_order") or 0,
)
session.add(obj)
await session.flush()
nmap[n["id"]] = obj.id
for r in tdata.get("relationships", []):
a = pmap.get(r.get("person_from_id"))
b = pmap.get(r.get("person_to_id"))
if a is None or b is None:
continue
obj = Relationship(
tree_id=tree.id,
type=r.get("type"),
person_from_id=a,
person_to_id=b,
qualifier=r.get("qualifier"),
notes=r.get("notes"),
)
session.add(obj)
await session.flush()
rmap[r["id"]] = obj.id
for e in tdata.get("events", []):
obj = Event(
tree_id=tree.id,
event_type=e.get("event_type") or "other",
person_id=pmap.get(e.get("person_id")),
relationship_id=rmap.get(e.get("relationship_id")),
place_id=plmap.get(e.get("place_id")),
date_value=e.get("date_value"),
date_start=_as_date(e.get("date_start")),
date_end=_as_date(e.get("date_end")),
date_precision=e.get("date_precision"),
calendar=e.get("calendar") or "gregorian",
detail=e.get("detail"),
notes=e.get("notes"),
)
session.add(obj)
await session.flush()
emap[e["id"]] = obj.id
counts["events"] += 1
for s in tdata.get("sources", []):
obj = Source(
tree_id=tree.id,
title=s.get("title") or "Untitled source",
author=s.get("author"),
source_type=s.get("source_type"),
repository=s.get("repository"),
url=s.get("url"),
citation_text=s.get("citation_text"),
publication_info=s.get("publication_info"),
quality_note=s.get("quality_note"),
)
session.add(obj)
await session.flush()
smap[s["id"]] = obj.id
for c in tdata.get("citations", []):
sid = smap.get(c.get("source_id"))
if sid is None:
continue
session.add(
Citation(
tree_id=tree.id,
source_id=sid,
person_id=pmap.get(c.get("person_id")),
event_id=emap.get(c.get("event_id")),
name_id=nmap.get(c.get("name_id")),
relationship_id=rmap.get(c.get("relationship_id")),
page=c.get("page"),
detail=c.get("detail"),
confidence=c.get("confidence"),
)
)
for m in tdata.get("media", []):
ref = m.get("_file")
if not ref:
continue
try:
blob = zf.read(ref)
except KeyError:
continue
media_id = uuid.uuid4()
filename = m.get("original_filename") or "upload"
key = f"{tree.id}/{media_id}/{filename}"
await store.ensure_bucket()
await store.put_object(
key=key,
data=blob,
content_type=m.get("content_type") or "application/octet-stream",
)
session.add(
Media(
id=media_id,
tree_id=tree.id,
uploader_id=user.id,
storage_key=key,
original_filename=filename,
content_type=m.get("content_type") or "application/octet-stream",
byte_size=len(blob),
checksum_sha256=hashlib.sha256(blob).hexdigest(),
title=m.get("title"),
person_id=pmap.get(m.get("person_id")),
event_id=emap.get(m.get("event_id")),
source_id=smap.get(m.get("source_id")),
)
)
counts["media"] += 1
# Remap the home person last, once persons exist.
home = t.get("home_person_id")
if home and home in pmap:
tree.home_person_id = pmap[home]
record_audit(
session,
action="import",
entity_type="Account",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=user.id,
after=counts,
)
await session.commit()
return counts
async def delete_account(session: AsyncSession, *, user: User, confirm_email: str) -> None:
"""Soft-delete the account: the user, the trees they own, and all their
sessions. Requires the user to retype their email as a guard."""
if confirm_email.strip().lower() != user.email.lower():
raise Forbidden("email confirmation does not match")
now = datetime.now(UTC)
await session.execute(
update(Tree)
.where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
.values(deleted_at=now)
)
await session.execute(
update(SessionModel)
.where(SessionModel.user_id == user.id, SessionModel.revoked_at.is_(None))
.values(revoked_at=now)
)
user.deleted_at = now
record_audit(
session,
action="delete",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
)
await session.commit()
+11 -2
View File
@@ -3,6 +3,7 @@ the change to a User (or the assistant principal acting for a User). Staged on
the session; the caller commits as part of its unit of work. the session; the caller commits as part of its unit of work.
""" """
import json
import uuid import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +12,14 @@ from app.models.audit import AuditEntry
from app.models.enums import AuditActorType from app.models.enums import AuditActorType
def _json_safe(d: dict | None) -> dict | None:
"""Coerce a change dict to JSON-native types (UUIDs, enums, dates -> str) so
it lands in the JSON audit column regardless of what the caller passed."""
if d is None:
return None
return json.loads(json.dumps(d, default=str))
def record_audit( def record_audit(
session: AsyncSession, session: AsyncSession,
*, *,
@@ -30,8 +39,8 @@ def record_audit(
tree_id=tree_id, tree_id=tree_id,
actor_user_id=actor_user_id, actor_user_id=actor_user_id,
actor_type=actor_type, actor_type=actor_type,
before=before, before=_json_safe(before),
after=after, after=_json_safe(after),
) )
session.add(entry) session.add(entry)
return entry return entry
+30 -3
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings from app.core.config import get_settings
from app.core.security import generate_token, hash_password, hash_token from app.core.security import generate_token, hash_password, hash_token, verify_password
from app.integrations.auth.local import LocalAuthProvider from app.integrations.auth.local import LocalAuthProvider
from app.integrations.mailer.base import Mailer from app.integrations.mailer.base import Mailer
from app.models.auth import Session as SessionModel from app.models.auth import Session as SessionModel
@@ -17,7 +17,7 @@ from app.models.auth import UserToken
from app.models.enums import TokenPurpose from app.models.enums import TokenPurpose
from app.models.user import User from app.models.user import User
from app.services.audit import record_audit from app.services.audit import record_audit
from app.services.exceptions import Conflict, NotFound from app.services.exceptions import Conflict, Forbidden, NotFound
_local_provider = LocalAuthProvider() _local_provider = LocalAuthProvider()
@@ -113,6 +113,8 @@ async def login(
user = await _local_provider.authenticate(session, identifier=email, secret=password) user = await _local_provider.authenticate(session, identifier=email, secret=password)
if user is None: if user is None:
return None return None
if get_settings().require_email_verification and user.email_verified_at is None:
raise Forbidden("email not verified — check your inbox for the verification link")
raw_token, record = _issue_session(session, user) raw_token, record = _issue_session(session, user)
record_audit( record_audit(
session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id
@@ -141,11 +143,16 @@ async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User
).scalar_one_or_none() ).scalar_one_or_none()
if record is None or record.revoked_at is not None or record.expires_at <= _now(): if record is None or record.revoked_at is not None or record.expires_at <= _now():
return None return None
return ( user = (
await session.execute( await session.execute(
select(User).where(User.id == record.user_id, User.deleted_at.is_(None)) select(User).where(User.id == record.user_id, User.deleted_at.is_(None))
) )
).scalar_one_or_none() ).scalar_one_or_none()
# The single read-side enforcement: an unverified user has no active session
# when verification is required. Gates every authenticated request at once.
if user is not None and get_settings().require_email_verification and user.email_verified_at is None:
return None
return user
async def verify_email(session: AsyncSession, *, raw_token: str) -> None: async def verify_email(session: AsyncSession, *, raw_token: str) -> None:
@@ -178,6 +185,26 @@ async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw)) await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
async def change_password(
session: AsyncSession, *, user: User, current_password: str, new_password: str
) -> None:
"""Change a logged-in user's password after re-verifying the current one.
Revokes other sessions so a changed password takes effect everywhere."""
if not user.hashed_password or not verify_password(
user.hashed_password, current_password
):
raise Forbidden("current password is incorrect")
user.hashed_password = hash_password(new_password)
record_audit(
session,
action="change_password",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
)
await session.commit()
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None: async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
token = await _consume_token(session, raw_token, TokenPurpose.password_reset) token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
await session.execute( await session.execute(
+32
View File
@@ -113,6 +113,38 @@ async def list_citations(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID, changes: dict
) -> Citation:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
citation = (
await session.execute(
select(Citation).where(
Citation.id == citation_id,
Citation.tree_id == tree.id,
Citation.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if citation is None:
raise NotFound("citation not found")
for key in {"page", "detail", "confidence"} & changes.keys():
setattr(citation, key, changes[key])
record_audit(
session,
action="update",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(citation)
return citation
async def delete_citation( async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
) -> None: ) -> None:
+337
View File
@@ -0,0 +1,337 @@
"""Bulk tree cleanup — preview/apply pairs for common import messes.
Per the project's #1 rule (the assistant proposes, humans approve), each fix has
a *preview* that returns the proposed changes and an *apply* that commits only
the ids/edits the user confirmed. Nothing here mutates without an explicit apply
call carrying the user's selections.
"""
import re
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import RelationshipType
from app.models.event import Event
from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.tree import Tree
from app.models.user import User
from app.services import gedcom, privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
from app.services.name_gender_data import guess_sex
async def _require_editor(session: AsyncSession, *, actor: User, tree: Tree) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
async def _persons(session: AsyncSession, tree_id: uuid.UUID) -> list[Person]:
return list(
(
await session.execute(
select(Person).where(Person.tree_id == tree_id, Person.deleted_at.is_(None))
)
).scalars().all()
)
async def _primary_name_by_person(
session: AsyncSession, tree_id: uuid.UUID
) -> dict[uuid.UUID, Name]:
names = (
await session.execute(
select(Name)
.where(Name.tree_id == tree_id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().all()
out: dict[uuid.UUID, Name] = {}
for n in names:
out.setdefault(n.person_id, n)
return out
async def _birth_year_by_person(session: AsyncSession, tree_id: uuid.UUID) -> dict[uuid.UUID, int]:
evs = (
await session.execute(
select(Event).where(
Event.tree_id == tree_id,
Event.deleted_at.is_(None),
Event.event_type == "birth",
)
)
).scalars().all()
out: dict[uuid.UUID, int] = {}
for e in evs:
if not e.person_id or e.person_id in out:
continue
y = e.date_start.year if e.date_start else None
if y is None:
ys = gedcom._year(e.date_value)
y = int(ys) if ys else None
if y is not None:
out[e.person_id] = y
return out
def _display(n: Name | None) -> str:
if n is None:
return "Unnamed"
return " ".join(x for x in (n.given, n.surname) if x) or (n.display_name or "Unnamed")
# ---- 1. Mark deceased by birth year -------------------------------------------------
async def preview_deceased(
session: AsyncSession, *, actor: User, tree: Tree, year: int
) -> list[dict]:
await _require_editor(session, actor=actor, tree=tree)
names = await _primary_name_by_person(session, tree.id)
years = await _birth_year_by_person(session, tree.id)
out: list[dict] = []
for p in await _persons(session, tree.id):
if p.is_living is False: # already deceased
continue
by = years.get(p.id)
if by is not None and by <= year:
out.append(
{"person_id": str(p.id), "name": _display(names.get(p.id)), "birth_year": by}
)
out.sort(key=lambda r: r["birth_year"])
return out
async def apply_deceased(
session: AsyncSession, *, actor: User, tree: Tree, person_ids: list[uuid.UUID]
) -> int:
await _require_editor(session, actor=actor, tree=tree)
persons = (
await session.execute(
select(Person).where(
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
Person.id.in_(person_ids),
)
)
).scalars().all()
for p in persons:
p.is_living = False
record_audit(
session,
action="cleanup_deceased",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"count": len(persons)},
)
await session.commit()
return len(persons)
# ---- 2. Re-derive gender from a source GEDCOM (matches by name) ----------------------
async def preview_gender(
session: AsyncSession, *, actor: User, tree: Tree, gedcom_text: str
) -> list[dict]:
await _require_editor(session, actor=actor, tree=tree)
name2sex: dict[str, str] = {}
for rec in gedcom.parse_records(gedcom_text):
if rec.tag != "INDI":
continue
summ = gedcom._person_summary(rec)
sex = gedcom._sex(rec.text("SEX"))
if sex and summ["norm"]:
name2sex.setdefault(summ["norm"], sex)
names = await _primary_name_by_person(session, tree.id)
out: list[dict] = []
for p in await _persons(session, tree.id):
if p.gender: # only fill in what's missing
continue
nm = names.get(p.id)
if nm is None:
continue
proposed = name2sex.get(gedcom._norm(nm.given, nm.surname))
if proposed:
out.append({"person_id": str(p.id), "name": _display(nm), "proposed_gender": proposed})
out.sort(key=lambda r: r["name"])
return out
async def guess_gender_by_name(
session: AsyncSession, *, actor: User, tree: Tree
) -> list[dict]:
"""Best-guess sex from the first given name for people who don't have it set,
using the bundled name dictionary. Ambiguous/unknown names are skipped."""
await _require_editor(session, actor=actor, tree=tree)
names = await _primary_name_by_person(session, tree.id)
out: list[dict] = []
for p in await _persons(session, tree.id):
if p.gender:
continue
nm = names.get(p.id)
if nm is None:
continue
proposed = guess_sex(nm.given)
if proposed:
out.append({"person_id": str(p.id), "name": _display(nm), "proposed_gender": proposed})
out.sort(key=lambda r: r["name"])
return out
async def guess_gender_by_spouse(
session: AsyncSession, *, actor: User, tree: Tree
) -> list[dict]:
"""Infer the sex of a person who has none set from a partner whose sex IS set
(couples in a tree are opposite-sex in practice — e.g. a confirmed-male
husband implies a female wife). People whose known partners disagree are
ambiguous and skipped; the result is a preview to review, not an auto-write."""
await _require_editor(session, actor=actor, tree=tree)
persons = await _persons(session, tree.id)
gender = {p.id: p.gender for p in persons}
names = await _primary_name_by_person(session, tree.id)
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.partnership,
)
)
).scalars().all()
opp = {"male": "female", "female": "male"}
proposals: dict[uuid.UUID, set[str]] = {}
for r in rels:
for me_id, other_id in (
(r.person_from_id, r.person_to_id),
(r.person_to_id, r.person_from_id),
):
if gender.get(me_id):
continue # this person already has a sex
other_sex = str(gender.get(other_id) or "")
if other_sex in opp:
proposals.setdefault(me_id, set()).add(opp[other_sex])
out: list[dict] = []
for pid, sexes in proposals.items():
if len(sexes) != 1:
continue # partners of differing known sex → ambiguous
nm = names.get(pid)
if nm is None:
continue
out.append(
{"person_id": str(pid), "name": _display(nm), "proposed_gender": next(iter(sexes))}
)
out.sort(key=lambda r: r["name"])
return out
async def apply_gender(
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
) -> int:
"""updates: [{person_id, gender}]."""
await _require_editor(session, actor=actor, tree=tree)
wanted = {uuid.UUID(str(u["person_id"])): u["gender"] for u in updates if u.get("gender")}
persons = (
await session.execute(
select(Person).where(
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
Person.id.in_(wanted.keys()),
)
)
).scalars().all()
for p in persons:
p.gender = wanted[p.id]
record_audit(
session,
action="cleanup_gender",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"count": len(persons)},
)
await session.commit()
return len(persons)
# ---- 3. Flag malformed names for review --------------------------------------------
_YEAR_RE = re.compile(r"\b\d{3,4}\b")
def _name_issue(n: Name) -> str | None:
given = (n.given or "").strip()
surname = (n.surname or "").strip()
if _YEAR_RE.search(surname) or re.search(r"\d", surname):
return "date_in_surname"
if re.search(r"\d", given):
return "date_in_given"
# A given name with many tokens often means a maiden+married name was packed
# in (e.g. "Mary Smith Jones") — surface it for a human to split.
if surname == "" and len(given.split()) >= 2:
return "no_surname"
if len(given.split()) >= 3:
return "packed_given"
return None
async def preview_names(session: AsyncSession, *, actor: User, tree: Tree) -> list[dict]:
await _require_editor(session, actor=actor, tree=tree)
names = (
await session.execute(
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
)
).scalars().all()
out: list[dict] = []
for n in names:
issue = _name_issue(n)
if issue:
out.append({
"name_id": str(n.id),
"person_id": str(n.person_id),
"given": n.given,
"surname": n.surname,
"issue": issue,
})
return out
async def apply_names(
session: AsyncSession, *, actor: User, tree: Tree, edits: list[dict]
) -> int:
"""edits: [{name_id, given, surname}] — the user's corrected values."""
await _require_editor(session, actor=actor, tree=tree)
by_id = {uuid.UUID(str(e["name_id"])): e for e in edits}
rows = (
await session.execute(
select(Name).where(
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
Name.id.in_(by_id.keys()),
)
)
).scalars().all()
if len(rows) != len(by_id):
raise NotFound("one or more names not found in this tree")
for n in rows:
e = by_id[n.id]
n.given = (e.get("given") or "").strip() or None
n.surname = (e.get("surname") or "").strip() or None
n.display_name = None # rebuild from parts
record_audit(
session,
action="cleanup_names",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"count": len(rows)},
)
await session.commit()
return len(rows)
+66
View File
@@ -91,11 +91,39 @@ async def create_event(
return event return event
async def list_events(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Event]:
"""All events in the tree — lets the family view compute birth/death years."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
# Non-members get the redacted projection (no living-person dates).
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
from app.services import public_view_service
return await public_view_service.list_public_events(
session, viewer_id=viewer_id, tree=tree
)
stmt = (
select(Event)
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
.order_by(Event.date_start.nulls_last(), Event.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_events_for_person( async def list_events_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Event]: ) -> list[Event]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree") raise Forbidden("not permitted to view this tree")
# Non-members only see a full-visibility person's events (redacted → none).
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
from app.services import public_view_service
return await public_view_service.list_public_person_events(
session, viewer_id=viewer_id, tree=tree, person_id=person_id
)
stmt = ( stmt = (
select(Event) select(Event)
.where( .where(
@@ -108,6 +136,44 @@ async def list_events_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_event(
session: AsyncSession,
*,
actor: User,
tree: Tree,
event_id: uuid.UUID,
changes: dict,
) -> Event:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
event = (
await session.execute(
select(Event).where(
Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if event is None:
raise NotFound("event not found")
if "place_id" in changes and changes["place_id"] is not None:
if not await _belongs_to_tree(session, Place, changes["place_id"], tree.id):
raise NotFound("place not found in this tree")
for key, value in changes.items():
setattr(event, key, value)
record_audit(
session,
action="update",
entity_type="Event",
entity_id=event.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(event)
return event
async def delete_event( async def delete_event(
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
) -> None: ) -> None:
+841
View File
@@ -0,0 +1,841 @@
"""GEDCOM import/export.
A pragmatic parser + mapper for the common subset of GEDCOM (5.5.1 / 7 share
the line grammar): INDI, FAM, SOUR. Import maps records into a tree and returns
a mapping report (counts + unmapped tags); export serializes the tree back to
GEDCOM. Runs inline for now — large files should move to the worker later.
Import is duplicate-aware: ``preview_gedcom`` reports incoming people that look
like existing ones, and ``import_gedcom`` applies a per-record resolution
(new / skip / merge / overwrite). Names carry their GEDCOM type (a married name
imports as a typed alternate, not a second primary).
"""
import re
import uuid
from collections import defaultdict
from datetime import UTC, date, datetime
from difflib import SequenceMatcher
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ParentChildQualifier, RelationshipType
from app.models.event import Event
from app.models.person import Name, Person
from app.models.place import Place
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden
# GEDCOM event tag -> our event_type (INDI-level).
INDI_EVENTS = {
"BIRT": "birth", "DEAT": "death", "BAPM": "baptism", "CHR": "christening",
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
"NATU": "naturalization", "BAPL": "baptism", "RELI": "religion",
}
# INDI attribute tags whose line VALUE is the fact (no date), stored in detail.
VALUE_EVENTS = {"RELI", "OCCU", "EDUC"}
# INDI sub-tags consumed elsewhere or intentionally ignored (not "unmapped").
INDI_SKIP_TAGS = {
"NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID", "_MARNM", "NOTE",
}
# FAM-level events.
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
# GEDCOM NAME TYPE (or _MARNM-derived) -> our Name.name_type vocabulary.
NAME_TYPE_MAP = {
"birth": "birth", "maiden": "birth", "married": "married",
"aka": "alias", "also known as": "alias", "nickname": "nickname",
"religious": "religious", "immigrant": "immigration",
"immigration": "immigration", "professional": "alias", "other": "alias",
}
# Our type -> GEDCOM TYPE on export (birth is the default; emit nothing).
EXPORT_TYPE_MAP = {
"married": "married", "alias": "aka", "nickname": "nickname",
"religious": "religious", "immigration": "immigrant",
}
class GedcomNode:
__slots__ = ("level", "tag", "value", "xref", "children")
def __init__(self, level: int, tag: str, value: str = "", xref: str | None = None):
self.level = level
self.tag = tag
self.value = value
self.xref = xref
self.children: list[GedcomNode] = []
def first(self, tag: str) -> "GedcomNode | None":
return next((c for c in self.children if c.tag == tag), None)
def all(self, tag: str) -> list["GedcomNode"]:
return [c for c in self.children if c.tag == tag]
def text(self, tag: str, default: str | None = None) -> str | None:
n = self.first(tag)
return n.value if n is not None else default
def parse_records(text: str) -> list[GedcomNode]:
roots: list[GedcomNode] = []
stack: list[GedcomNode] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = raw.lstrip("").rstrip()
if not line.strip():
continue
parts = line.split(" ", 1)
try:
level = int(parts[0])
except ValueError:
continue
rest = parts[1] if len(parts) > 1 else ""
xref: str | None = None
if rest.startswith("@"):
end = rest.find("@", 1)
if end != -1:
xref = rest[: end + 1]
rest = rest[end + 1:].strip()
tparts = rest.split(" ", 1)
tag = tparts[0]
value = tparts[1] if len(tparts) > 1 else ""
while stack and stack[-1].level >= level:
stack.pop()
parent = stack[-1] if stack else None
if tag in ("CONC", "CONT") and parent is not None:
parent.value += ("" if tag == "CONC" else "\n") + value
continue
node = GedcomNode(level, tag, value, xref)
if parent is None:
roots.append(node)
else:
parent.children.append(node)
stack.append(node)
return roots
def _parse_name(value: str) -> tuple[str | None, str | None]:
if "/" in value:
given, _, rest = value.partition("/")
surname = rest.split("/", 1)[0]
return given.strip() or None, surname.strip() or None
return value.strip() or None, None
def _parse_marnm(value: str, base_given: str | None) -> tuple[str | None, str | None]:
"""A _MARNM value is sometimes a full name ("Jane /Smith/") and sometimes
just the married surname ("Smith"). Keep the given name from the base name
in the latter case."""
v = (value or "").strip()
if "/" in v:
g, s = _parse_name(v)
return (g or base_given), s
return base_given, (v or None)
def _extract_names(rec: GedcomNode) -> list[dict]:
"""All names for an INDI, typed. Multiple NAME records (each with an optional
TYPE) plus any _MARNM (married name) subtags become separate Name rows. The
first birth/maiden name is primary."""
out: list[dict] = []
for nm in rec.all("NAME"):
g, s = _parse_name(nm.value)
t = (nm.text("TYPE") or "").strip().lower()
ntype = NAME_TYPE_MAP.get(t, t or "birth")
out.append({"type": ntype, "given": g, "surname": s, "display": nm.value or None,
"nickname": nm.text("NICK")})
for mar in nm.all("_MARNM"):
mg, ms = _parse_marnm(mar.value, g)
out.append({"type": "married", "given": mg, "surname": ms,
"display": mar.value or None, "nickname": None})
for mar in rec.all("_MARNM"):
base_g = out[0]["given"] if out else None
mg, ms = _parse_marnm(mar.value, base_g)
out.append({"type": "married", "given": mg, "surname": ms,
"display": mar.value or None, "nickname": None})
if not out:
return out
primary_idx = next((i for i, n in enumerate(out) if n["type"] == "birth"), 0)
for i, n in enumerate(out):
n["is_primary"] = i == primary_idx
n["sort"] = i
return out
def _norm(given: str | None, surname: str | None) -> str:
return re.sub(r"\s+", " ", f"{given or ''} {surname or ''}".strip().lower())
def _year(date_value: str | None) -> str | None:
if not date_value:
return None
m = re.search(r"\b(\d{3,4})\b", date_value)
return m.group(1) if m else None
def _date_start(date_value: str | None) -> date | None:
y = _year(date_value)
if not y:
return None
try:
return date(int(y), 1, 1)
except ValueError:
return None
def _sex(value: str | None) -> str | None:
if not value:
return None
v = value.strip().upper()
return {"M": "male", "F": "female"}.get(v, value.strip().lower() or None)
def _notes_text(rec: GedcomNode) -> str | None:
"""Join an INDI's NOTE lines (which pack confidence / findagrave / fs_pid /
free text) into the person's notes field."""
vals = [n.value.strip() for n in rec.all("NOTE") if n.value and n.value.strip()]
return "\n".join(vals) or None
def _person_summary(rec: GedcomNode) -> dict:
"""Display name + birth year for an incoming INDI, for duplicate matching."""
names = _extract_names(rec)
primary = next((n for n in names if n.get("is_primary")), names[0] if names else None)
g = primary["given"] if primary else None
s = primary["surname"] if primary else None
disp = " ".join(x for x in (g, s) if x)
if not disp and primary:
disp = primary.get("display") or ""
birth = rec.first("BIRT")
year = _year(birth.text("DATE")) if birth else None
return {"names": names, "norm": _norm(g, s), "name": disp or "(no name)", "year": year}
async def _build_existing_index(session: AsyncSession, tree: Tree) -> list[dict]:
"""Existing (non-deleted) people with a display name + birth year, for
matching incoming records against."""
persons = list(
(
await session.execute(
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
)
).scalars().all()
)
names = list(
(
await session.execute(
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
)
).scalars().all()
)
name_by_person: dict[uuid.UUID, Name] = {}
for n in sorted(names, key=lambda n: (not n.is_primary, n.sort_order)):
name_by_person.setdefault(n.person_id, n)
births = list(
(
await session.execute(
select(Event).where(
Event.tree_id == tree.id,
Event.deleted_at.is_(None),
Event.event_type == "birth",
)
)
).scalars().all()
)
year_by_person: dict[uuid.UUID, str] = {}
for e in births:
if e.person_id and e.person_id not in year_by_person:
y = str(e.date_start.year) if e.date_start else _year(e.date_value)
if y:
year_by_person[e.person_id] = y
index: list[dict] = []
for p in persons:
nm = name_by_person.get(p.id)
g = nm.given if nm else None
s = nm.surname if nm else None
disp = " ".join(x for x in (g, s) if x) or (nm.display_name if nm else None)
index.append({
"id": p.id,
"norm": _norm(g, s),
"name": disp or "(no name)",
"year": year_by_person.get(p.id),
})
return index
def _best_match(norm: str, year: str | None, index: list[dict]) -> tuple[dict | None, str | None]:
"""Closest existing person by name similarity, rejecting clear birth-year
conflicts. Returns (entry, "high"|"medium") or (None, None)."""
if not norm:
return None, None
best: dict | None = None
best_r = 0.0
for e in index:
if not e["norm"]:
continue
r = SequenceMatcher(None, norm, e["norm"]).ratio()
if r < 0.88:
continue
if year and e["year"] and abs(int(year) - int(e["year"])) > 1:
continue # same-ish name but different birth year — not a duplicate
if r > best_r:
best_r = r
best = e
if best is None:
return None, None
year_match = bool(year and best["year"] and abs(int(year) - int(best["year"])) <= 1)
both_unknown = not year and not best["year"]
score = "high" if best_r >= 0.93 and (year_match or both_unknown) else "medium"
return best, score
def _relkey(rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID) -> tuple:
if rtype == RelationshipType.parent_child:
return ("pc", str(a), str(b))
return (rtype.value, *sorted([str(a), str(b)]))
def _count_incoming(roots: list[GedcomNode]) -> tuple[dict, list[str]]:
counts: dict[str, int] = defaultdict(int)
unmapped: set[str] = set()
for rec in roots:
if rec.tag == "INDI" and rec.xref:
counts["persons"] += 1
counts["names"] += len(_extract_names(rec))
for child in rec.children:
if child.tag in INDI_EVENTS:
counts["events"] += 1
elif child.tag not in INDI_SKIP_TAGS:
unmapped.add(child.tag)
elif rec.tag == "FAM":
counts["families"] += 1
for child in rec.children:
if child.tag in FAM_EVENTS:
counts["events"] += 1
elif rec.tag == "SOUR" and rec.xref:
counts["sources"] += 1
return dict(counts), sorted(unmapped)
async def preview_gedcom(session: AsyncSession, *, actor: User, tree: Tree, text: str) -> dict:
"""Dry run: what would import, and which incoming people look like existing
ones. No writes."""
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
roots = parse_records(text)
counts, unmapped = _count_incoming(roots)
index = await _build_existing_index(session, tree)
duplicates: list[dict] = []
for rec in roots:
if rec.tag != "INDI" or not rec.xref:
continue
summ = _person_summary(rec)
entry, score = _best_match(summ["norm"], summ["year"], index)
if entry is None:
continue
duplicates.append({
"xref": rec.xref,
"incoming_name": summ["name"],
"incoming_birth_year": summ["year"],
"existing_person_id": entry["id"],
"existing_name": entry["name"],
"existing_birth_year": entry["year"],
"score": score,
})
return {"counts": counts, "potential_duplicates": duplicates, "unmapped_tags": unmapped}
async def import_gedcom(
session: AsyncSession,
*,
actor: User,
tree: Tree,
text: str,
default_action: str = "new",
resolutions: dict | None = None,
) -> dict:
"""Import records. ``default_action`` (new|skip|merge|overwrite) applies to
incoming people that match an existing one; ``resolutions`` overrides it per
GEDCOM xref ({xref: {action, target_id}}). 'skip' links families to the
existing person but copies nothing; 'merge' also copies the incoming names
(as alternates), events and citations onto them; 'overwrite' deletes the
existing person and imports the incoming one fresh."""
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
resolutions = resolutions or {}
roots = parse_records(text)
counts: dict[str, int] = defaultdict(int)
unmapped: set[str] = set()
place_cache: dict[str, uuid.UUID] = {}
source_map: dict[str, uuid.UUID] = {}
person_map: dict[str, uuid.UUID] = {}
now = datetime.now(UTC)
index = await _build_existing_index(session, tree)
# Pre-load existing relationship keys so a merge doesn't create dup edges.
existing_rels = list(
(
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
)
)
).scalars().all()
)
rel_keys = {_relkey(r.type, r.person_from_id, r.person_to_id) for r in existing_rels}
def add_relationship(
rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID, **kw
) -> Relationship | None:
key = _relkey(rtype, a, b)
if key in rel_keys:
return None
rel = Relationship(tree_id=tree.id, type=rtype, person_from_id=a, person_to_id=b, **kw)
session.add(rel)
rel_keys.add(key)
counts["relationships"] += 1
return rel
async def place_id(name: str | None) -> uuid.UUID | None:
if not name:
return None
if name in place_cache:
return place_cache[name]
p = Place(tree_id=tree.id, name=name)
session.add(p)
await session.flush()
place_cache[name] = p.id
counts["places"] += 1
return p.id
# Sources first (so citations can reference them).
for rec in roots:
if rec.tag == "SOUR" and rec.xref:
src = Source(
tree_id=tree.id,
title=rec.text("TITL") or rec.text("ABBR") or "Untitled source",
author=rec.text("AUTH"),
publication_info=rec.text("PUBL"),
citation_text=rec.text("TEXT"),
)
session.add(src)
await session.flush()
source_map[rec.xref] = src.id
counts["sources"] += 1
async def add_citations(holder: GedcomNode, **target) -> None:
for s in holder.all("SOUR"):
sid = source_map.get(s.value.strip())
if sid is None:
continue
session.add(Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target))
counts["citations"] += 1
def add_names(person_id: uuid.UUID, names: list[dict], *, set_primary: bool) -> None:
for nd in names:
session.add(
Name(
tree_id=tree.id,
person_id=person_id,
name_type=nd["type"],
given=nd["given"],
surname=nd["surname"],
nickname=nd.get("nickname"),
display_name=nd.get("display"),
is_primary=set_primary and nd.get("is_primary", False),
sort_order=nd.get("sort", 0),
)
)
counts["names"] += 1
async def add_events(rec: GedcomNode, person_id: uuid.UUID) -> None:
for child in rec.children:
if child.tag in INDI_EVENTS:
dv = child.text("DATE")
# Attribute-style facts (RELI, OCCU, EDUC) carry their value on
# the line itself; store it in detail.
detail = child.value.strip() if child.tag in VALUE_EVENTS else None
ev = Event(
tree_id=tree.id,
person_id=person_id,
event_type=INDI_EVENTS[child.tag],
date_value=dv,
date_start=_date_start(dv),
place_id=await place_id(child.text("PLAC")),
detail=detail or None,
notes=child.text("NOTE"),
)
session.add(ev)
await session.flush()
counts["events"] += 1
await add_citations(child, event_id=ev.id)
elif child.tag in INDI_SKIP_TAGS:
continue
else:
unmapped.add(child.tag)
async def soft_delete_existing(person_id: uuid.UUID) -> None:
p = (
await session.execute(
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
)
).scalar_one_or_none()
if p is None:
return
p.deleted_at = now
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person_id,
Relationship.person_to_id == person_id,
),
)
)
).scalars().all()
for r in rels:
r.deleted_at = now
await session.execute(
update(User).where(User.self_person_id == person_id).values(self_person_id=None)
)
# Precompute the best match per incoming xref (for default-policy resolution).
matches: dict[str, dict] = {}
for rec in roots:
if rec.tag == "INDI" and rec.xref:
summ = _person_summary(rec)
entry, _score = _best_match(summ["norm"], summ["year"], index)
if entry is not None:
matches[rec.xref] = entry
def resolve(xref: str) -> tuple[str, uuid.UUID | None]:
ov = resolutions.get(xref)
if ov:
action = ov.get("action", "new")
tid = ov.get("target_id")
target = uuid.UUID(tid) if tid else (matches[xref]["id"] if xref in matches else None)
if action in ("skip", "merge", "overwrite") and target is None:
return "new", None
return action, target
if default_action != "new" and xref in matches:
return default_action, matches[xref]["id"]
return "new", None
# Individuals.
for rec in roots:
if rec.tag != "INDI" or not rec.xref:
continue
names = _extract_names(rec)
action, target = resolve(rec.xref)
if action == "skip" and target is not None:
person_map[rec.xref] = target
counts["skipped"] += 1
continue
if action == "merge" and target is not None:
person_map[rec.xref] = target
add_names(target, names, set_primary=False)
await add_events(rec, target)
await add_citations(rec, person_id=target)
note = _notes_text(rec)
if note:
existing = (
await session.execute(select(Person).where(Person.id == target))
).scalar_one_or_none()
if existing is not None:
existing.notes = "\n".join(filter(None, [existing.notes, note]))
counts["merged"] += 1
continue
if action == "overwrite" and target is not None:
await soft_delete_existing(target)
counts["overwritten"] += 1
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")), notes=_notes_text(rec))
session.add(person)
await session.flush()
person_map[rec.xref] = person.id
counts["persons"] += 1
add_names(person.id, names, set_primary=True)
await add_citations(rec, person_id=person.id)
await add_events(rec, person.id)
# Families -> partnerships, parent-child edges, marriage events.
for rec in roots:
if rec.tag != "FAM":
continue
counts["families"] += 1
husb = person_map.get((rec.text("HUSB") or "").strip())
wife = person_map.get((rec.text("WIFE") or "").strip())
partnership_id: uuid.UUID | None = None
if husb and wife and husb != wife:
rel = add_relationship(RelationshipType.partnership, husb, wife)
if rel is not None:
await session.flush()
partnership_id = rel.id
if partnership_id is None and husb and wife:
# Edge already existed — find it so marriage events can attach.
existing = next(
(
r for r in existing_rels
if r.type == RelationshipType.partnership
and {r.person_from_id, r.person_to_id} == {husb, wife}
),
None,
)
partnership_id = existing.id if existing else None
for fe in rec.children:
if fe.tag in FAM_EVENTS and partnership_id is not None:
dv = fe.text("DATE")
ev = Event(
tree_id=tree.id,
relationship_id=partnership_id,
event_type=FAM_EVENTS[fe.tag],
date_value=dv,
date_start=_date_start(dv),
place_id=await place_id(fe.text("PLAC")),
)
session.add(ev)
await session.flush()
counts["events"] += 1
for chil in rec.all("CHIL"):
cp = person_map.get(chil.value.strip())
if cp is None:
continue
for parent in (husb, wife):
if parent and parent != cp:
add_relationship(
RelationshipType.parent_child,
parent,
cp,
qualifier=ParentChildQualifier.biological,
)
record_audit(
session,
action="import",
entity_type="Gedcom",
tree_id=tree.id,
actor_user_id=actor.id,
after=dict(counts),
)
await session.commit()
return {"counts": dict(counts), "unmapped_tags": sorted(unmapped)}
def _ged_date(value: str | None) -> str | None:
return value.strip() if value else None
async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> str:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
persons = list(
(
await session.execute(
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
)
).scalars().all()
)
names = list(
(
await session.execute(
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
)
).scalars().all()
)
events = list(
(
await session.execute(
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
)
).scalars().all()
)
rels = list(
(
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
)
)
).scalars().all()
)
sources = list(
(
await session.execute(
select(Source).where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
)
).scalars().all()
)
places = {
p.id: p
for p in (
await session.execute(select(Place).where(Place.tree_id == tree.id))
).scalars().all()
}
citations = list(
(
await session.execute(
select(Citation).where(
Citation.tree_id == tree.id, Citation.deleted_at.is_(None)
)
)
).scalars().all()
)
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
gender_by_id = {p.id: p.gender for p in persons}
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
# Citations grouped by the fact they sit on, so each fact can emit its SOUR
# links (dropping these is the round-trip data loss this fixes). Skip any
# whose source didn't export.
cite_by_person: dict[uuid.UUID, list[Citation]] = defaultdict(list)
cite_by_name: dict[uuid.UUID, list[Citation]] = defaultdict(list)
cite_by_event: dict[uuid.UUID, list[Citation]] = defaultdict(list)
cite_by_rel: dict[uuid.UUID, list[Citation]] = defaultdict(list)
for c in citations:
if c.source_id not in sxref:
continue
if c.person_id:
cite_by_person[c.person_id].append(c)
elif c.event_id:
cite_by_event[c.event_id].append(c)
elif c.name_id:
cite_by_name[c.name_id].append(c)
elif c.relationship_id:
cite_by_rel[c.relationship_id].append(c)
def cite_lines(cites: list[Citation], depth: int) -> list[str]:
lines: list[str] = []
for c in cites:
lines.append(f"{depth} SOUR {sxref[c.source_id]}")
if c.page:
lines.append(f"{depth + 1} PAGE {c.page}")
return lines
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
names_by_person[n.person_id].append(n)
events_by_person: dict[uuid.UUID, list[Event]] = defaultdict(list)
events_by_rel: dict[uuid.UUID, list[Event]] = defaultdict(list)
for e in events:
if e.person_id:
events_by_person[e.person_id].append(e)
elif e.relationship_id:
events_by_rel[e.relationship_id].append(e)
# Build families from parent-child + partnership edges (group by parent set).
parents_of: dict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
for r in rels:
if r.type == RelationshipType.parent_child:
parents_of[r.person_to_id].add(r.person_from_id)
fams: dict[frozenset, dict] = {}
for child, ps in parents_of.items():
key = frozenset(ps)
fams.setdefault(key, {"parents": set(ps), "children": [], "rel_id": None})
fams[key]["children"].append(child)
for r in rels:
if r.type == RelationshipType.partnership:
key = frozenset({r.person_from_id, r.person_to_id})
fam = fams.setdefault(
key,
{"parents": {r.person_from_id, r.person_to_id}, "children": [], "rel_id": None},
)
fam["rel_id"] = r.id
fam_list = list(fams.values())
fxref = {id(f): f"@F{i + 1}@" for i, f in enumerate(fam_list)}
# person -> the families they are a spouse in / a child in
spouse_fams: dict[uuid.UUID, list[str]] = defaultdict(list)
child_fams: dict[uuid.UUID, str] = {}
for f in fam_list:
x = fxref[id(f)]
for pid in f["parents"]:
spouse_fams[pid].append(x)
for cid in f["children"]:
child_fams[cid] = x
out: list[str] = ["0 HEAD", "1 SOUR Provenance", "1 GEDC", "2 VERS 5.5.1", "1 CHAR UTF-8"]
for p in persons:
out.append(f"0 {pxref[p.id]} INDI")
for n in names_by_person.get(p.id, []):
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
out.append(f"1 NAME {display}")
ged_type = EXPORT_TYPE_MAP.get(n.name_type)
if ged_type:
out.append(f"2 TYPE {ged_type}")
out += cite_lines(cite_by_name.get(n.id, []), 2)
sex = {"male": "M", "female": "F"}.get(p.gender or "")
if sex:
out.append(f"1 SEX {sex}")
for e in events_by_person.get(p.id, []):
tag = EVENT_TO_GED.get(e.event_type)
if not tag:
continue
out.append(f"1 {tag}")
if _ged_date(e.date_value):
out.append(f"2 DATE {e.date_value}")
if e.place_id and e.place_id in places:
out.append(f"2 PLAC {places[e.place_id].name}")
out += cite_lines(cite_by_event.get(e.id, []), 2)
out += cite_lines(cite_by_person.get(p.id, []), 1)
if p.id in child_fams:
out.append(f"1 FAMC {child_fams[p.id]}")
for x in spouse_fams.get(p.id, []):
out.append(f"1 FAMS {x}")
for f in fam_list:
x = fxref[id(f)]
out.append(f"0 {x} FAM")
ps = list(f["parents"])
# HUSB/WIFE by recorded gender where possible.
males = [pid for pid in ps if gender_by_id.get(pid) == "male"]
females = [pid for pid in ps if gender_by_id.get(pid) == "female"]
husb = males[0] if males else (ps[0] if ps else None)
wife = females[0] if females else next((pid for pid in ps if pid != husb), None)
if husb:
out.append(f"1 HUSB {pxref[husb]}")
if wife:
out.append(f"1 WIFE {pxref[wife]}")
for cid in f["children"]:
out.append(f"1 CHIL {pxref[cid]}")
if f["rel_id"]:
for e in events_by_rel.get(f["rel_id"], []):
tag = EVENT_TO_GED.get(e.event_type)
if not tag:
continue
out.append(f"1 {tag}")
if _ged_date(e.date_value):
out.append(f"2 DATE {e.date_value}")
out += cite_lines(cite_by_event.get(e.id, []), 2)
out += cite_lines(cite_by_rel.get(f["rel_id"], []), 1)
for s in sources:
out.append(f"0 {sxref[s.id]} SOUR")
if s.title:
out.append(f"1 TITL {s.title}")
if s.author:
out.append(f"1 AUTH {s.author}")
if s.publication_info:
out.append(f"1 PUBL {s.publication_info}")
out.append("0 TRLR")
return "\n".join(out) + "\n"
+46
View File
@@ -72,6 +72,13 @@ async def upload_media(
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]: async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree") raise Forbidden("not permitted to view this tree")
# Non-members only see media of a FULL-visibility person (no living-person photos).
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
from app.services import public_view_service
return await public_view_service.list_public_media(
session, viewer_id=viewer_id, tree=tree
)
stmt = ( stmt = (
select(Media) select(Media)
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None)) .where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
@@ -94,6 +101,45 @@ async def get_media(
).scalar_one_or_none() ).scalar_one_or_none()
if media is None: if media is None:
raise NotFound("media not found") raise NotFound("media not found")
# Non-members may only see/download media of a FULL-visibility person. 404
# (not 403) so the item's existence isn't revealed. This gates media_content.
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
from app.services import public_view_service
if not await public_view_service.can_view_media(
session, viewer_id=viewer_id, tree=tree, media=media
):
raise NotFound("media not found")
return media
async def update_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID, changes: dict
) -> Media:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
for key in {"title", "person_id", "event_id", "source_id"} & changes.keys():
setattr(media, key, changes[key])
record_audit(
session,
action="update",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(media)
return media return media
+69
View File
@@ -0,0 +1,69 @@
"""A curated given-name -> sex lookup for best-guessing a person's sex from
their first name. Weighted toward English + German names (this codebase's first
real tree is a German-American family). Deterministic and offline — no model
needed; the Cleanup tool previews every guess before anything is applied.
Genuinely ambiguous names (Marion, Frances/Francis, Jordan, Jamie, Robin, Leslie,
Dana, …) are intentionally left out of BOTH sets so they aren't guessed — better
a human decides those than a coin flip.
"""
MALE_NAMES: set[str] = {
# English / common US
"james", "john", "robert", "michael", "william", "david", "richard", "joseph",
"thomas", "charles", "christopher", "daniel", "matthew", "anthony", "donald",
"mark", "paul", "steven", "andrew", "kenneth", "george", "joshua", "kevin",
"brian", "edward", "ronald", "timothy", "jason", "jeffrey", "gary", "ryan",
"nicholas", "eric", "stephen", "jacob", "larry", "frank", "jonathan", "scott",
"raymond", "gregory", "samuel", "benjamin", "patrick", "jack", "dennis", "jerry",
"alexander", "tyler", "henry", "douglas", "peter", "adam", "harold", "albert",
"arthur", "carl", "ralph", "roy", "eugene", "louis", "philip", "bobby", "walter",
"willie", "wayne", "fred", "howard", "ernest", "earl", "clarence", "leon",
"leonard", "lewis", "floyd", "leroy", "elmer", "homer", "orrin", "josias",
"emerson", "dale", "bernard", "vernon", "virgil", "wilbur", "russell",
"harvey", "herbert", "melvin", "lloyd", "marvin", "norman", "stanley",
# German
"hans", "karl", "wilhelm", "friedrich", "heinrich", "otto", "hermann", "gustav",
"ludwig", "ernst", "fritz", "johann", "conrad", "konrad", "reinhold", "rudolf",
"rudolph", "gerhard", "helmut", "horst", "klaus", "kurt", "dieter", "günther",
"gunther", "manfred", "siegfried", "hilgard", "christian", "august", "wolfgang",
"jürgen", "jurgen", "matthias", "lothar", "bruno", "gottlieb", "reinhard",
}
FEMALE_NAMES: set[str] = {
# English / common US
"mary", "patricia", "jennifer", "linda", "elizabeth", "barbara", "susan",
"jessica", "sarah", "karen", "nancy", "lisa", "betty", "margaret", "sandra",
"ashley", "kimberly", "emily", "donna", "michelle", "carol", "amanda", "dorothy",
"melissa", "deborah", "stephanie", "rebecca", "sharon", "laura", "cynthia",
"kathleen", "amy", "angela", "shirley", "anna", "ruth", "brenda", "pamela",
"nicole", "katherine", "virginia", "catherine", "helen", "debra", "rachel",
"carolyn", "janet", "maria", "heather", "diane", "julie", "joyce", "victoria",
"kelly", "christina", "joan", "evelyn", "judith", "megan", "alice", "frances",
"marie", "florence", "flora", "zella", "thelma", "ellen", "althea", "della",
"beatrice", "pauline", "hedwig", "florentine", "wilhelmina", "augusta", "bertha",
"gladys", "mildred", "lucille", "edith", "esther", "irene", "hazel", "doris",
"rose", "rita", "norma", "june", "lois", "marjorie",
# German
"greta", "ilse", "ursula", "gertrud", "gertrude", "frieda", "frida", "else",
"hilda", "hilde", "hildegard", "ingrid", "helga", "renate", "monika", "sieglinde",
"brigitte", "gisela", "elke", "anneliese", "waltraud", "edeltraud", "johanna",
"katharina", "margarethe", "wilhelmine", "emilie", "auguste",
}
def guess_sex(given: str | None) -> str | None:
"""Best-guess "male"/"female" from the first token of a given name, or None
if unknown/ambiguous."""
if not given:
return None
first = given.strip().split()[0].lower() if given.strip() else ""
# Strip trailing punctuation/initials like "wm." -> "wm".
first = first.strip(".,'\"")
if not first:
return None
if first in MALE_NAMES:
return "male"
if first in FEMALE_NAMES:
return "female"
return None
+215
View File
@@ -0,0 +1,215 @@
"""Name service. A Person carries one or more Name rows — a primary (typically
the birth/maiden name) plus typed alternates (married, alias, religious, …).
Exactly one name is primary at a time; it drives display everywhere. Writes
require editor rights; reads go through the tree's view check.
"""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Name, Person
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
async def _get_person(session: AsyncSession, *, tree: Tree, person_id: uuid.UUID) -> Person:
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
return person
async def _clear_primary(
session: AsyncSession, *, person_id: uuid.UUID, keep: uuid.UUID | None
) -> None:
"""Demote every other name so exactly one stays primary."""
stmt = (
update(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None), Name.is_primary.is_(True))
.values(is_primary=False)
)
if keep is not None:
stmt = stmt.where(Name.id != keep)
await session.execute(stmt)
async def list_names(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Name]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
await _get_person(session, tree=tree, person_id=person_id)
# Non-members: a redacted/hidden person's real names must not leak.
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
from app.services import public_view_service
return await public_view_service.list_public_person_names(
session, viewer_id=viewer_id, tree=tree, person_id=person_id
)
stmt = (
select(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def create_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_type: str = "birth",
given: str | None = None,
surname: str | None = None,
prefix: str | None = None,
suffix: str | None = None,
nickname: str | None = None,
is_primary: bool = False,
) -> Name:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
await _get_person(session, tree=tree, person_id=person_id)
# First name for a person is always primary; otherwise honor the flag.
existing = (
await session.execute(
select(Name.id).where(Name.person_id == person_id, Name.deleted_at.is_(None))
)
).first()
primary = is_primary or existing is None
if primary:
await _clear_primary(session, person_id=person_id, keep=None)
name = Name(
tree_id=tree.id,
person_id=person_id,
name_type=name_type,
given=given,
surname=surname,
prefix=prefix,
suffix=suffix,
nickname=nickname,
is_primary=primary,
)
session.add(name)
await session.flush()
record_audit(
session,
action="create",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"name_type": name_type, "given": given, "surname": surname},
)
await session.commit()
await session.refresh(name)
return name
_NAME_FIELDS = {"name_type", "given", "surname", "prefix", "suffix", "nickname"}
async def update_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_id: uuid.UUID,
changes: dict,
) -> Name:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
name = (
await session.execute(
select(Name).where(
Name.id == name_id,
Name.person_id == person_id,
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if name is None:
raise NotFound("name not found")
for key in _NAME_FIELDS & changes.keys():
setattr(name, key, changes[key])
if changes.get("is_primary") is True:
await _clear_primary(session, person_id=person_id, keep=name.id)
name.is_primary = True
record_audit(
session,
action="update",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(name)
return name
async def delete_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_id: uuid.UUID,
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
name = (
await session.execute(
select(Name).where(
Name.id == name_id,
Name.person_id == person_id,
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if name is None:
raise NotFound("name not found")
name.deleted_at = datetime.now(UTC)
was_primary = name.is_primary
name.is_primary = False
record_audit(
session,
action="delete",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
# Promote another name to primary so the person never loses their display name.
if was_primary:
nxt = (
await session.execute(
select(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
.order_by(Name.sort_order, Name.created_at)
)
).scalars().first()
if nxt is not None:
nxt.is_primary = True
await session.commit()
+295 -13
View File
@@ -4,12 +4,14 @@ person through the privacy engine. Each returned Person gets a transient
""" """
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import PersonPrivacy from app.models.enums import PersonPrivacy, RelationshipType
from app.models.person import Name, Person from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.tree import Tree from app.models.tree import Tree
from app.models.user import User from app.models.user import User
from app.services import privacy from app.services import privacy
@@ -24,6 +26,14 @@ def _format_name(name: Name) -> str | None:
return joined or name.display_name return joined or name.display_name
def _redact(person: Person) -> None:
"""Minimise a possibly-living person for a non-member view (transient only —
never committed)."""
person.primary_name = "Living person"
person.gender = None
person.is_living = True
async def _attach_primary_name(session: AsyncSession, person: Person) -> None: async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
stmt = ( stmt = (
select(Name) select(Name)
@@ -86,6 +96,59 @@ async def create_person(
return person return person
_PERSON_FIELDS = {"gender", "is_living", "privacy", "notes"}
async def update_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID, changes: dict
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
for key in _PERSON_FIELDS & changes.keys():
setattr(person, key, changes[key])
if "given" in changes or "surname" in changes:
name = (
await session.execute(
select(Name)
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().first()
if name is None:
name = Name(tree_id=tree.id, person_id=person.id, name_type="birth", is_primary=True)
session.add(name)
if "given" in changes:
name.given = changes["given"]
if "surname" in changes:
name.surname = changes["surname"]
name.display_name = None # rebuild display from parts
record_audit(
session,
action="update",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def get_person( async def get_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> Person: ) -> Person:
@@ -103,15 +166,181 @@ async def get_person(
if person is None: if person is None:
raise NotFound("person not found") raise NotFound("person not found")
# Run the single person through the privacy engine (redaction lands Phase 2). # Run the single person through the privacy engine (redaction lands Phase 2).
if ( vis = await privacy.person_visibility(
await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person) session, user_id=viewer_id, tree=tree, person=person
== Visibility.hidden )
): if vis == Visibility.hidden:
raise NotFound("person not found") raise NotFound("person not found")
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
return person
async def _children_of(
session: AsyncSession, *, tree_id: uuid.UUID, parent_id: uuid.UUID
) -> list[uuid.UUID]:
rows = (
await session.execute(
select(Relationship.person_to_id).where(
Relationship.tree_id == tree_id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.parent_child,
Relationship.person_from_id == parent_id,
)
)
).scalars().all()
return list(rows)
async def _soft_delete_one(
session: AsyncSession, *, actor: User, tree: Tree, person: Person, now: datetime
) -> None:
"""Soft-delete a single person and the relationships touching them, so no
dangling edges are left to break the tree view."""
person.deleted_at = now
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person.id,
Relationship.person_to_id == person.id,
),
)
)
).scalars().all()
for rel in rels:
rel.deleted_at = now
record_audit(
session,
action="delete",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"cascaded_relationships": len(rels)},
)
async def delete_person(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
cascade: bool = False,
) -> int:
"""Soft-delete a person. Always removes the relationships that touch them
(preventing dangling edges). With ``cascade=True``, recursively deletes
their descendants too — handy for pruning a bad GEDCOM import. Returns the
number of persons deleted."""
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
now = datetime.now(UTC)
# Gather the set of persons to delete. For cascade, walk descendants
# breadth-first, guarding against cycles.
to_delete: list[Person] = [person]
if cascade:
seen = {person.id}
frontier = [person.id]
while frontier:
nxt: list[uuid.UUID] = []
for pid in frontier:
for child_id in await _children_of(session, tree_id=tree.id, parent_id=pid):
if child_id not in seen:
seen.add(child_id)
nxt.append(child_id)
frontier = nxt
extra_ids = [pid for pid in seen if pid != person.id]
if extra_ids:
extra = (
await session.execute(
select(Person).where(
Person.id.in_(extra_ids),
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
)
)
).scalars().all()
to_delete.extend(extra)
for p in to_delete:
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
# never fires — clear any links (account self-person, tree home person) to a
# deleted person.
deleted_ids = [p.id for p in to_delete]
await session.execute(
update(User).where(User.self_person_id.in_(deleted_ids)).values(self_person_id=None)
)
await session.execute(
update(Tree).where(Tree.home_person_id.in_(deleted_ids)).values(home_person_id=None)
)
await session.commit()
return len(to_delete)
async def restore_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("deleted person not found")
person.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person) await _attach_primary_name(session, person)
return person return person
async def list_deleted_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Person)
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
.order_by(Person.deleted_at.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
for person in persons:
await _attach_primary_name(session, person)
return persons
async def list_persons( async def list_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]: ) -> list[Person]:
@@ -127,13 +356,66 @@ async def list_persons(
visible: list[Person] = [] visible: list[Person] = []
for person in persons: for person in persons:
if ( vis = await privacy.person_visibility(
await privacy.person_visibility( session, user_id=viewer_id, tree=tree, person=person
session, user_id=viewer_id, tree=tree, person=person )
) if vis == Visibility.hidden:
== Visibility.hidden
):
continue continue
await _attach_primary_name(session, person) if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
visible.append(person) visible.append(person)
return visible return visible
async def search_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
q = query.strip()
if not q:
return []
like = f"%{q}%"
score = func.greatest(
func.similarity(func.coalesce(Name.given, ""), q),
func.similarity(func.coalesce(Name.surname, ""), q),
)
sub = (
select(Name.person_id.label("pid"), func.max(score).label("score"))
.where(
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
or_(
Name.given.op("%")(q),
Name.surname.op("%")(q),
Name.given.ilike(like),
Name.surname.ilike(like),
),
)
.group_by(Name.person_id)
.order_by(func.max(score).desc())
.limit(limit)
.subquery()
)
stmt = (
select(Person)
.join(sub, sub.c.pid == Person.id)
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
.order_by(sub.c.score.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
out: list[Person] = []
for person in persons:
vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person
)
if vis == Visibility.hidden:
continue
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
out.append(person)
return out
+59 -3
View File
@@ -8,14 +8,20 @@ tree's visibility, the per-person override, and (Phase 2) living-person status.
import enum import enum
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
from app.models.event import Event
from app.models.person import Person from app.models.person import Person
from app.models.tree import Tree, TreeMembership from app.models.tree import Tree, TreeMembership
# A person with no death fact whose birth is within this window (or unknown) is
# treated as possibly living and redacted from non-members (ARCHITECTURE §6).
LIVING_RECENCY_YEARS = 100
class Visibility(enum.StrEnum): class Visibility(enum.StrEnum):
full = "full" full = "full"
@@ -39,8 +45,17 @@ async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre
if tree.deleted_at is not None: if tree.deleted_at is not None:
return False return False
if await get_membership_role(session, user_id, tree.id) is not None: if await get_membership_role(session, user_id, tree.id) is not None:
return True # members always (any role)
# Non-members. Branch on the viewer's auth state:
# public / unlisted → anyone, including anonymous (unlisted is gated only
# by knowing the link, so the API must never *list* it).
# site_members → any authenticated account on this instance.
# private → no one.
if tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted):
return True return True
return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted) if tree.visibility == TreeVisibility.site_members:
return user_id is not None
return False
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool: async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
@@ -48,15 +63,56 @@ async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre
return role in (MembershipRole.owner, MembershipRole.editor) return role in (MembershipRole.owner, MembershipRole.editor)
async def is_possibly_living(session: AsyncSession, person: Person) -> bool:
"""True if the person should be treated as living: explicit flag, or (absent
a death fact) a birth within the recency window or an unknown birth."""
if person.is_living is True:
return True
if person.is_living is False:
return False
death = (
await session.execute(
select(Event.id)
.where(
Event.person_id == person.id,
Event.event_type == "death",
Event.deleted_at.is_(None),
)
.limit(1)
)
).scalar_one_or_none()
if death is not None:
return False
birth = (
await session.execute(
select(Event.date_start)
.where(
Event.person_id == person.id,
Event.event_type == "birth",
Event.date_start.is_not(None),
Event.deleted_at.is_(None),
)
.order_by(Event.date_start)
.limit(1)
)
).scalar_one_or_none()
if birth is None:
return True # unknown birth → treat as possibly living
return (datetime.now(UTC).year - birth.year) < LIVING_RECENCY_YEARS
async def person_visibility( async def person_visibility(
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
) -> Visibility: ) -> Visibility:
if not await can_view_tree(session, user_id=user_id, tree=tree): if not await can_view_tree(session, user_id=user_id, tree=tree):
return Visibility.hidden return Visibility.hidden
if await get_membership_role(session, user_id, tree.id) is not None: if await get_membership_role(session, user_id, tree.id) is not None:
return Visibility.full return Visibility.full # members see everyone in their tree
# Non-member viewing a public/unlisted tree: # Non-member viewing a public/unlisted tree:
if person.privacy == PersonPrivacy.private: if person.privacy == PersonPrivacy.private:
return Visibility.hidden return Visibility.hidden
# TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6). if person.privacy == PersonPrivacy.public:
return Visibility.full # explicit per-person opt-in
if await is_possibly_living(session, person):
return Visibility.redacted # living people are protected by default
return Visibility.full return Visibility.full
+318
View File
@@ -0,0 +1,318 @@
"""Read-only, redaction-safe projections for the public viewing surface.
INVARIANT (CLAUDE.md #2): everything returned here has passed through
``privacy.person_visibility``. A non-member must never receive a possibly-living
person's real name, dates, alternate names, or media. The rules:
- persons : redacted (living → "Living person"); hidden dropped.
- relationships : only when BOTH endpoints are non-hidden (a link to a
redacted person is fine — the name is already hidden).
- events : only for FULL-visibility persons; partnership events only
when BOTH partners are full (a marriage date would leak a
living partner's timeline otherwise).
- names : only for FULL-visibility persons.
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md).
A tree that isn't viewable raises NotFound (never Forbidden) so the public
surface can't be used to probe whether a private tree exists.
"""
import uuid
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import TreeVisibility
from app.models.event import Event
from app.models.media import Media
from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.tree import Tree
from app.services import privacy
from app.services.exceptions import NotFound
from app.services.person_service import _attach_primary_name, _redact
from app.services.privacy import Visibility
async def get_public_tree(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree_id: uuid.UUID
) -> Tree:
tree = (
await session.execute(
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
)
).scalar_one_or_none()
# 404 (not 403) when not viewable: don't reveal that a private tree exists.
if tree is None or not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise NotFound("tree not found")
return tree
async def _persons(session: AsyncSession, tree: Tree) -> list[Person]:
return list(
(
await session.execute(
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
)
)
.scalars()
.all()
)
async def _visibility_map(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, persons: list[Person]
) -> dict[uuid.UUID, Visibility]:
return {
p.id: await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=p
)
for p in persons
}
async def list_public_persons(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> list[Person]:
out: list[Person] = []
for p in await _persons(session, tree):
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=p)
if vis == Visibility.hidden:
continue
if vis == Visibility.redacted:
_redact(p)
else:
await _attach_primary_name(session, p)
out.append(p)
return out
async def get_public_person(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
) -> Person:
person = (
await session.execute(
select(Person).where(
Person.id == person_id,
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
if vis == Visibility.hidden:
raise NotFound("person not found")
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
return person
async def _person_visibility(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
) -> Visibility | None:
person = (
await session.execute(
select(Person).where(
Person.id == person_id,
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if person is None:
return None
return await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
async def list_public_relationships(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> list[Relationship]:
persons = await _persons(session, tree)
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
nonhidden = {pid for pid, v in vis.items() if v != Visibility.hidden}
rels = list(
(
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
)
)
)
.scalars()
.all()
)
return [
r for r in rels if r.person_from_id in nonhidden and r.person_to_id in nonhidden
]
async def list_public_events(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> list[Event]:
persons = await _persons(session, tree)
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
full = {pid for pid, v in vis.items() if v == Visibility.full}
rels = {
r.id: r
for r in (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
)
)
)
.scalars()
.all()
}
events = list(
(
await session.execute(
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
)
)
.scalars()
.all()
)
out: list[Event] = []
for e in events:
if e.person_id is not None:
if e.person_id in full:
out.append(e)
elif e.relationship_id is not None:
r = rels.get(e.relationship_id)
if r is not None and r.person_from_id in full and r.person_to_id in full:
out.append(e)
return out
async def list_public_person_names(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
) -> list[Name]:
vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id)
if vis is None:
raise NotFound("person not found")
if vis != Visibility.full:
return [] # redacted/hidden → no names (the real name must not leak)
return list(
(
await session.execute(
select(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
)
)
.scalars()
.all()
)
async def list_public_person_events(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
) -> list[Event]:
vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id)
if vis is None:
raise NotFound("person not found")
if vis != Visibility.full:
return [] # redacted/hidden → no dates
return list(
(
await session.execute(
select(Event)
.where(
Event.person_id == person_id,
Event.tree_id == tree.id,
Event.deleted_at.is_(None),
)
.order_by(Event.date_start.nulls_last(), Event.created_at)
)
)
.scalars()
.all()
)
async def list_public_relationships_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
) -> list[Relationship]:
persons = await _persons(session, tree)
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
if vis.get(person_id) in (None, Visibility.hidden):
return []
nonhidden = {pid for pid, v in vis.items() if v != Visibility.hidden}
rels = list(
(
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person_id,
Relationship.person_to_id == person_id,
),
)
)
)
.scalars()
.all()
)
return [r for r in rels if r.person_from_id in nonhidden and r.person_to_id in nonhidden]
async def list_public_media(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> list[Media]:
"""Only media linked to a FULL-visibility person. Media without a person (or
linked only to an event/source) is not exposed to non-members — a photo of a
living person must never leak."""
persons = await _persons(session, tree)
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
full = {pid for pid, v in vis.items() if v == Visibility.full}
media = list(
(
await session.execute(
select(Media).where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
)
)
.scalars()
.all()
)
return [m for m in media if m.person_id is not None and m.person_id in full]
async def can_view_media(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, media: Media
) -> bool:
"""Whether a non-member may see/download a single media item: only when it is
linked to a FULL-visibility person."""
if media.person_id is None:
return False
vis = await _person_visibility(
session, viewer_id=viewer_id, tree=tree, person_id=media.person_id
)
return vis == Visibility.full
async def list_public_trees(
session: AsyncSession,
*,
viewer_id: uuid.UUID | None,
q: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Tree]:
# Anonymous: only `public`. Authenticated: also `site_members`. Never list
# `unlisted` (reachable by link only) or `private`.
allowed = [TreeVisibility.public]
if viewer_id is not None:
allowed.append(TreeVisibility.site_members)
stmt = select(Tree).where(
Tree.deleted_at.is_(None), Tree.visibility.in_(allowed)
)
if q and q.strip():
stmt = stmt.where(Tree.name.ilike(f"%{q.strip()}%"))
stmt = stmt.order_by(Tree.name).limit(min(limit, 100)).offset(max(offset, 0))
return list((await session.execute(stmt)).scalars().all())
+98 -1
View File
@@ -4,7 +4,7 @@ Writes require editor rights; reads go through the privacy engine."""
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import or_, select from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ParentChildQualifier, RelationshipType from app.models.enums import ParentChildQualifier, RelationshipType
@@ -49,6 +49,38 @@ async def create_relationship(
if not await _person_in_tree(session, pid, tree.id): if not await _person_in_tree(session, pid, tree.id):
raise NotFound("person not found in this tree") raise NotFound("person not found in this tree")
# Reject an equivalent existing edge so the same two people can't be linked
# the same way twice. parent_child is directional (parent -> child);
# partnership/sibling are symmetric, so match the pair in either order.
if type is RelationshipType.parent_child:
pair = and_(
Relationship.person_from_id == person_from_id,
Relationship.person_to_id == person_to_id,
)
else:
pair = or_(
and_(
Relationship.person_from_id == person_from_id,
Relationship.person_to_id == person_to_id,
),
and_(
Relationship.person_from_id == person_to_id,
Relationship.person_to_id == person_from_id,
),
)
existing = (
await session.execute(
select(Relationship.id).where(
Relationship.tree_id == tree.id,
Relationship.type == type,
Relationship.deleted_at.is_(None),
pair,
)
)
).scalar_one_or_none()
if existing is not None:
raise Conflict("these two people are already linked that way")
relationship = Relationship( relationship = Relationship(
tree_id=tree.id, tree_id=tree.id,
type=type, type=type,
@@ -73,11 +105,38 @@ async def create_relationship(
return relationship return relationship
async def list_relationships(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Relationship]:
"""All relationships in the tree — powers the family/pedigree view in one call."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
# Non-members: drop relationships touching a hidden person.
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
from app.services import public_view_service
return await public_view_service.list_public_relationships(
session, viewer_id=viewer_id, tree=tree
)
stmt = (
select(Relationship)
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
.order_by(Relationship.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_relationships_for_person( async def list_relationships_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Relationship]: ) -> list[Relationship]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree") raise Forbidden("not permitted to view this tree")
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
from app.services import public_view_service
return await public_view_service.list_public_relationships_for_person(
session, viewer_id=viewer_id, tree=tree, person_id=person_id
)
stmt = ( stmt = (
select(Relationship) select(Relationship)
.where( .where(
@@ -93,6 +152,44 @@ async def list_relationships_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID, changes: dict
) -> Relationship:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
relationship = (
await session.execute(
select(Relationship).where(
Relationship.id == relationship_id,
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if relationship is None:
raise NotFound("relationship not found")
if (
"qualifier" in changes
and changes["qualifier"] is not None
and relationship.type is not RelationshipType.parent_child
):
raise Conflict("qualifier only applies to parent_child relationships")
for key in {"qualifier", "notes"} & changes.keys():
setattr(relationship, key, changes[key])
record_audit(
session,
action="update",
entity_type="Relationship",
entity_id=relationship.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(relationship)
return relationship
async def delete_relationship( async def delete_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID
) -> None: ) -> None:
+36
View File
@@ -86,6 +86,42 @@ async def get_source(
return source return source
_SOURCE_FIELDS = {
"title", "author", "source_type", "repository", "url", "citation_text",
"publication_info", "quality_note",
}
async def update_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID, changes: dict
) -> Source:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
for key in _SOURCE_FIELDS & changes.keys():
setattr(source, key, changes[key])
record_audit(
session,
action="update",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(source)
return source
async def delete_source( async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
) -> None: ) -> None:
+77
View File
@@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
""" """
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -59,3 +60,79 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree") raise Forbidden("not permitted to view this tree")
return tree return tree
async def update_tree(
session: AsyncSession, *, actor: User, tree_id: uuid.UUID, changes: dict
) -> Tree:
tree = await BaseRepository(session, Tree).get(tree_id)
if tree is None:
raise NotFound("tree not found")
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
for key in {"name", "description", "visibility", "home_person_id"} & changes.keys():
setattr(tree, key, changes[key])
record_audit(
session,
action="update",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(tree)
return tree
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
"""Load a tree (including soft-deleted) and require the actor be its owner."""
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
if tree is None:
raise NotFound("tree not found")
role = await privacy.get_membership_role(session, actor.id, tree.id)
if role is not MembershipRole.owner:
raise Forbidden("only the owner can delete or restore a tree")
return tree
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is None:
tree.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is not None:
tree.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
return tree
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
stmt = (
select(Tree)
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None))
.order_by(Tree.deleted_at.desc())
)
return list((await session.execute(stmt)).scalars().all())
+40 -1
View File
@@ -8,10 +8,13 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Person
from app.models.tree import Tree
from app.models.user import User from app.models.user import User
from app.repositories.base import BaseRepository from app.repositories.base import BaseRepository
from app.services import privacy
from app.services.audit import record_audit from app.services.audit import record_audit
from app.services.exceptions import Conflict from app.services.exceptions import Conflict, Forbidden, NotFound
async def create_user( async def create_user(
@@ -42,3 +45,39 @@ async def create_user(
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None: async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
return await BaseRepository(session, User).get(user_id) return await BaseRepository(session, User).get(user_id)
async def set_self_person(
session: AsyncSession, *, user: User, person_id: uuid.UUID | None
) -> User:
"""Point a user's account at the Person record that *is* them ("home
person"), or clear it with ``None``. The person must live in a tree the
user can view."""
if person_id is not None:
person = (
await session.execute(
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
tree = (
await session.execute(select(Tree).where(Tree.id == person.tree_id))
).scalar_one_or_none()
if tree is None or not await privacy.can_view_tree(
session, user_id=user.id, tree=tree
):
raise Forbidden("not permitted to link this person")
user.self_person_id = person_id
record_audit(
session,
action="update",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
after={"self_person_id": str(person_id) if person_id else None},
)
await session.commit()
await session.refresh(user)
return user
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Container entrypoint. When RUN_MIGRATIONS=1 (set on the backend service),
# apply DB migrations before handing off to the command. This makes a deploy
# self-migrating even when images are swapped in place (e.g. by Watchtower),
# without a separate orchestration step. `alembic upgrade head` is idempotent —
# a no-op when the schema is already current.
set -e
if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
echo "[entrypoint] applying database migrations (alembic upgrade head)…"
uv run --no-dev alembic upgrade head
fi
exec "$@"
@@ -0,0 +1,33 @@
"""pg_trgm extension + trigram name indexes for fuzzy search
Revision ID: 9a2b1c7d4e10
Revises: 7fc7024ef432
Create Date: 2026-06-07
"""
from collections.abc import Sequence
from alembic import op
revision: str = "9a2b1c7d4e10"
down_revision: str | None = "7fc7024ef432"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_names_given_trgm "
"ON names USING gin (given gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_names_surname_trgm "
"ON names USING gin (surname gin_trgm_ops)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_names_surname_trgm")
op.execute("DROP INDEX IF EXISTS ix_names_given_trgm")
# Leave the pg_trgm extension in place; other features may rely on it.
@@ -0,0 +1,36 @@
"""user.self_person_id ("home person" link)
Revision ID: b3d5f8a1c920
Revises: 9a2b1c7d4e10
Create Date: 2026-06-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "b3d5f8a1c920"
down_revision: str | None = "9a2b1c7d4e10"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("self_person_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
"fk_users_self_person_id",
"users",
"persons",
["self_person_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_users_self_person_id", "users", type_="foreignkey")
op.drop_column("users", "self_person_id")
@@ -0,0 +1,33 @@
"""tree.home_person_id (per-tree default/home person)
Revision ID: c7e1a4f2d3b8
Revises: b3d5f8a1c920
Create Date: 2026-06-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c7e1a4f2d3b8"
down_revision: str | None = "b3d5f8a1c920"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("trees", sa.Column("home_person_id", sa.Uuid(), nullable=True))
op.create_foreign_key(
"fk_trees_home_person_id",
"trees",
"persons",
["home_person_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_trees_home_person_id", "trees", type_="foreignkey")
op.drop_column("trees", "home_person_id")
@@ -0,0 +1,28 @@
"""tree_visibility: add 'site_members' value
Revision ID: d4a9c1e7b2f3
Revises: c7e1a4f2d3b8
Create Date: 2026-06-09
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d4a9c1e7b2f3"
down_revision: str | None = "c7e1a4f2d3b8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
# Postgres; run it in an autocommit block so it applies regardless of version.
with op.get_context().autocommit_block():
op.execute("ALTER TYPE tree_visibility ADD VALUE IF NOT EXISTS 'site_members'")
def downgrade() -> None:
# Postgres cannot drop an enum value without rebuilding the type; treat the
# added value as irreversible. (Rows using it would block a rebuild anyway.)
pass
+18 -4
View File
@@ -11,6 +11,7 @@ import os
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — register all models on Base.metadata import app.models # noqa: F401 — register all models on Base.metadata
@@ -66,15 +67,21 @@ def mailer() -> CapturingMailer:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def client(): async def engine():
if not TEST_DATABASE_URL: if not TEST_DATABASE_URL:
pytest.skip("TEST_DATABASE_URL not set") pytest.skip("TEST_DATABASE_URL not set")
engine = create_async_engine(TEST_DATABASE_URL) eng = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn: async with eng.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def client(engine):
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def _override_session(): async def _override_session():
@@ -93,7 +100,14 @@ async def client():
yield http_client yield http_client
app.dependency_overrides.clear() app.dependency_overrides.clear()
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
"""A raw AsyncSession on the test DB, for unit-testing services directly."""
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async with sessionmaker() as session:
yield session
def token_from_link(link: str) -> str: def token_from_link(link: str) -> str:
+86
View File
@@ -0,0 +1,86 @@
"""Account export -> restore round-trip, and account deletion."""
from tests.conftest import auth, register
async def _seed(client, h):
tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=h)).json()["id"]
p1 = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Lovelace"}, headers=h
)
).json()["id"]
p2 = (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Kid"}, headers=h)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": p1, "person_to_id": p2},
headers=h,
)
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": p1, "date_value": "1815"},
headers=h,
)
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("scan.txt", b"hello", "text/plain")},
data={"title": "Scan", "person_id": p1},
headers=h,
)
return tid
async def test_export_then_restore_roundtrip(client):
h = auth(await register(client, "exp@example.com"))
await _seed(client, h)
export = await client.get("/api/v1/users/me/export", headers=h)
assert export.status_code == 200
assert export.headers["content-type"] == "application/zip"
blob = export.content
assert blob[:2] == b"PK" # zip magic
# Restore into new trees (non-destructive: the original stays).
r = await client.post(
"/api/v1/users/me/import",
files={"file": ("provenance-export.zip", blob, "application/zip")},
headers=h,
)
assert r.status_code == 200, r.text
counts = r.json()
assert counts["trees"] == 1 and counts["persons"] == 2
assert counts["events"] == 1 and counts["media"] == 1
trees = (await client.get("/api/v1/trees", headers=h)).json()
assert len(trees) == 2 # original + restored
# The restored tree has the people, with a working relationship and media.
restored = [t for t in trees if t["name"] == "Fam"][1]["id"]
ppl = (await client.get(f"/api/v1/trees/{restored}/persons", headers=h)).json()
assert {p["primary_name"] for p in ppl} == {"Ada Lovelace", "Kid"}
rels = (await client.get(f"/api/v1/trees/{restored}/relationships", headers=h)).json()
assert len(rels) == 1
med = (await client.get(f"/api/v1/trees/{restored}/media", headers=h)).json()
assert len(med) == 1 and med[0]["title"] == "Scan"
async def test_delete_account_requires_email_then_revokes(client):
token = await register(client, "del@example.com")
h = auth(token)
await _seed(client, h)
# Wrong email is rejected.
bad = await client.request(
"DELETE", "/api/v1/users/me", data={"confirm_email": "nope@example.com"}, headers=h
)
assert bad.status_code == 403
ok = await client.request(
"DELETE", "/api/v1/users/me", data={"confirm_email": "del@example.com"}, headers=h
)
assert ok.status_code == 204
# Session is revoked — the token no longer works.
assert (await client.get("/api/v1/users/me", headers=h)).status_code == 401
+53
View File
@@ -0,0 +1,53 @@
"""Change password and per-tree home person."""
from tests.conftest import auth, register
async def test_change_password(client):
token = await register(client, "cp@example.com", password="password123")
h = auth(token)
# Wrong current password is rejected.
bad = await client.post(
"/api/v1/auth/change-password",
json={"current_password": "nope", "new_password": "newpass123"},
headers=h,
)
assert bad.status_code == 403
ok = await client.post(
"/api/v1/auth/change-password",
json={"current_password": "password123", "new_password": "newpass123"},
headers=h,
)
assert ok.status_code == 204
# The new password logs in; the old one does not.
assert (
await client.post(
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "newpass123"}
)
).status_code == 200
assert (
await client.post(
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "password123"}
)
).status_code == 401
async def test_tree_home_person(client):
h = auth(await register(client, "home@example.com"))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Root"}, headers=h)
).json()["id"]
r = await client.patch(
f"/api/v1/trees/{tid}", json={"home_person_id": pid}, headers=h
)
assert r.status_code == 200 and r.json()["home_person_id"] == pid
# Deleting the home person clears the link.
await client.delete(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
tree = (await client.get(f"/api/v1/trees/{tid}", headers=h)).json()
assert tree["home_person_id"] is None
+41
View File
@@ -104,3 +104,44 @@ async def test_logout_revokes_session(client):
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200 assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204 assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401 assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
async def test_unverified_user_works_by_default(client):
# Default (require_email_verification off): unverified accounts work as before.
token = await register(client, "open@example.com")
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
async def test_verification_gate_blocks_until_verified(client, mailer, monkeypatch):
from app.core.config import get_settings
monkeypatch.setattr(get_settings(), "require_email_verification", True)
reg = await client.post(
"/api/v1/auth/register", json={"email": "gate@example.com", "password": "password123"}
)
assert reg.status_code == 201
token = reg.json()["token"]
# The session issued at registration does not resolve while unverified...
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
# ...and login is refused with 403 (not 401 — credentials are valid).
blocked = await client.post(
"/api/v1/auth/login", json={"email": "gate@example.com", "password": "password123"}
)
assert blocked.status_code == 403
# Verify via the emailed link.
link = mailer.verifications[-1][1]
assert (
await client.post("/api/v1/auth/verify-email", json={"token": token_from_link(link)})
).status_code == 204
# Now login works and the session resolves.
ok = await client.post(
"/api/v1/auth/login", json={"email": "gate@example.com", "password": "password123"}
)
assert ok.status_code == 200
assert (
await client.get("/api/v1/users/me", headers=auth(ok.json()["token"]))
).status_code == 200
@@ -0,0 +1,121 @@
"""Authed non-member reads must redact PER-PERSON, not just gate on the tree.
A logged-in user who is NOT a member of a public tree previously saw living
people's dates, real alternate names, and media through the family-view
endpoints — only the person *list* was redacted. These tests assert that leak is
closed while members still see everything.
"""
from tests.conftest import auth, register
LSURNAME = "Authleaksurname"
LALIAS = "Authleakalias"
LYEAR = "2003"
async def _setup(client):
owner = auth(await register(client, "anm-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Pub", "visibility": "public"}, headers=owner
)
).json()["id"]
old = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Olde", "surname": "Gone", "is_living": False},
headers=owner,
)
).json()["id"]
young = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Youngauth", "surname": LSURNAME, "is_living": True},
headers=owner,
)
).json()["id"]
for pid, year in ((old, "1855"), (young, LYEAR)):
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": pid, "date_value": year},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/persons/{young}/names",
json={"name_type": "alias", "given": LALIAS},
headers=owner,
)
om = (
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("o.txt", b"old-photo", "text/plain")},
data={"person_id": old},
headers=owner,
)
).json()["id"]
ym = (
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("y.txt", b"young-photo", "text/plain")},
data={"person_id": young},
headers=owner,
)
).json()["id"]
return owner, tid, old, young, om, ym
async def test_authed_nonmember_does_not_see_living_pii(client):
owner, tid, old, young, om, ym = await _setup(client)
stranger = auth(await register(client, "anm-stranger@ex.com"))
# Living person's events dropped; deceased kept.
events = (await client.get(f"/api/v1/trees/{tid}/events", headers=stranger)).json()
assert any(e["person_id"] == old for e in events)
assert not any(e["person_id"] == young for e in events)
# Per-person living: names + events empty.
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=stranger)
).json() == []
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{young}/events", headers=stranger)
).json() == []
# The living surname/alias/birth-year must not appear in any of these.
for path in (
f"/api/v1/trees/{tid}/events",
f"/api/v1/trees/{tid}/relationships",
f"/api/v1/trees/{tid}/persons/{young}/names",
f"/api/v1/trees/{tid}/media",
):
body = (await client.get(path, headers=stranger)).text
assert LSURNAME not in body, path
assert LALIAS not in body, path
assert LYEAR not in body, path
# Media: living person's media hidden from the list and undownloadable;
# deceased person's media is fine.
media_ids = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=stranger)).json()}
assert om in media_ids
assert ym not in media_ids
assert (
await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=stranger)
).status_code == 404
assert (
await client.get(f"/api/v1/trees/{tid}/media/{om}/content", headers=stranger)
).status_code == 200
async def test_member_still_sees_everything(client):
owner, tid, old, young, om, ym = await _setup(client)
events = (await client.get(f"/api/v1/trees/{tid}/events", headers=owner)).json()
assert any(e["person_id"] == young for e in events)
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=owner)
).json() != []
member_media = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=owner)).json()}
assert ym in member_media
assert (
await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=owner)
).status_code == 200
+167
View File
@@ -0,0 +1,167 @@
"""Tree cleanup: preview/apply for deceased-by-year, gender-from-source, names."""
from tests.conftest import auth, register
async def _tree(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def _person(client, h, tid, given, surname=None):
return (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": given, "surname": surname}, headers=h
)
).json()["id"]
async def _birth(client, h, tid, pid, year):
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": pid, "date_value": str(year)},
headers=h,
)
async def test_deceased_preview_and_apply(client):
h, tid = await _tree(client, "cl-dec@example.com")
old = await _person(client, h, tid, "Josias", "Moody")
young = await _person(client, h, tid, "Kid", "Moody")
await _birth(client, h, tid, old, 1900)
await _birth(client, h, tid, young, 1990)
prev = (
await client.get(f"/api/v1/trees/{tid}/cleanup/deceased?born_on_or_before=1930", headers=h)
).json()
assert [r["person_id"] for r in prev] == [old]
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/deceased", json={"person_ids": [old]}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 1
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{old}", headers=h)
).json()["is_living"] is False
# Re-preview no longer lists the now-deceased person.
prev2 = (
await client.get(f"/api/v1/trees/{tid}/cleanup/deceased?born_on_or_before=1930", headers=h)
).json()
assert old not in [r["person_id"] for r in prev2]
async def test_gender_from_spouse_preview_and_apply(client):
h, tid = await _tree(client, "cl-spouse@example.com")
husband = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Otto", "surname": "Frey", "gender": "male"},
headers=h,
)
).json()["id"]
wife = await _person(client, h, tid, "Bea", "Frey") # no sex
loner = await _person(client, h, tid, "Nyx", "Alone") # no sex, no partner
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": husband, "person_to_id": wife},
headers=h,
)
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
by = {r["person_id"]: r["proposed_gender"] for r in prev}
assert by.get(wife) == "female" # opposite of the confirmed-male husband
assert loner not in by # no known-sex partner → not proposed
assert husband not in by # already has a sex
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/gender",
json={"updates": [{"person_id": wife, "gender": "female"}]},
headers=h,
)
assert r.status_code == 200 and r.json()["updated"] == 1
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{wife}", headers=h)
).json()["gender"] == "female"
# Once set, the wife is no longer proposed.
prev2 = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
assert wife not in [r["person_id"] for r in prev2]
GED = b"""0 HEAD
0 @I1@ INDI
1 NAME Josias /Moody/
1 SEX M
0 @I2@ INDI
1 NAME Flora /Paul/
1 SEX F
0 TRLR
"""
async def test_gender_from_source(client):
h, tid = await _tree(client, "cl-gen@example.com")
await _person(client, h, tid, "Josias", "Moody")
await _person(client, h, tid, "Flora", "Paul")
await _person(client, h, tid, "Nobody", "Else") # not in source
prev = await client.post(
f"/api/v1/trees/{tid}/cleanup/gender/preview",
files={"file": ("src.ged", GED, "text/plain")},
headers=h,
)
props = prev.json()
by_name = {p["name"]: p["proposed_gender"] for p in props}
assert by_name == {"Josias Moody": "male", "Flora Paul": "female"}
updates = [{"person_id": p["person_id"], "gender": p["proposed_gender"]} for p in props]
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/gender", json={"updates": updates}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 2
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
genders = {p["primary_name"]: p["gender"] for p in people}
assert genders["Josias Moody"] == "male" and genders["Flora Paul"] == "female"
async def test_guess_gender_from_first_name(client):
h, tid = await _tree(client, "cl-guess@example.com")
await _person(client, h, tid, "William", "Paul") # male
await _person(client, h, tid, "Flora", "Reier") # female
await _person(client, h, tid, "Marion", "Doe") # ambiguous -> skipped
# Already-gendered person is left alone even if guessable.
gendered = await _person(client, h, tid, "James", "Known")
await client.patch(
f"/api/v1/trees/{tid}/persons/{gendered}", json={"gender": "male"}, headers=h
)
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/guess", headers=h)).json()
by = {p["name"]: p["proposed_gender"] for p in prev}
assert by == {"William Paul": "male", "Flora Reier": "female"}
updates = [{"person_id": p["person_id"], "gender": p["proposed_gender"]} for p in prev]
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/gender", json={"updates": updates}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 2
async def test_name_issues_preview_and_fix(client):
h, tid = await _tree(client, "cl-name@example.com")
# surname got a date; real surname landed in the given name.
bad = await _person(client, h, tid, "Henry Paul", "1859")
await _person(client, h, tid, "Normal", "Person") # should not be flagged
issues = (await client.get(f"/api/v1/trees/{tid}/cleanup/names", headers=h)).json()
assert len(issues) == 1 and issues[0]["issue"] == "date_in_surname"
name_id = issues[0]["name_id"]
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/names",
json={"edits": [{"name_id": name_id, "given": "Henry", "surname": "Paul"}]},
headers=h,
)
assert r.status_code == 200 and r.json()["updated"] == 1
person = (await client.get(f"/api/v1/trees/{tid}/persons/{bad}", headers=h)).json()
assert person["primary_name"] == "Henry Paul"
+19
View File
@@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client):
assert resp.status_code == 403 assert resp.status_code == 403
async def test_person_update(client):
token = await register(client, "edit@example.com")
h = auth(token)
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Jon", "surname": "Smith"}, headers=h
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tid}/persons/{pid}",
json={"given": "John", "gender": "male"},
headers=auth(token),
)
assert resp.status_code == 200, resp.text
assert resp.json()["primary_name"] == "John Smith"
assert resp.json()["gender"] == "male"
async def test_auth_required_without_token(client): async def test_auth_required_without_token(client):
resp = await client.get("/api/v1/trees") resp = await client.get("/api/v1/trees")
assert resp.status_code == 401 assert resp.status_code == 401
+79
View File
@@ -0,0 +1,79 @@
"""Update (the U in CRUD) for the remaining entities — rule #8."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def test_tree_update(client):
h, tid = await _setup(client, "u-tree@example.com")
r = await client.patch(
f"/api/v1/trees/{tid}", json={"name": "Renamed", "visibility": "unlisted"}, headers=h
)
assert r.status_code == 200
assert r.json()["name"] == "Renamed" and r.json()["visibility"] == "unlisted"
async def test_source_update(client):
h, tid = await _setup(client, "u-src@example.com")
sid = (
await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "Old"}, headers=h)
).json()["id"]
r = await client.patch(
f"/api/v1/trees/{tid}/sources/{sid}",
json={"title": "New", "repository": "NARA"},
headers=h,
)
assert r.status_code == 200
assert r.json()["title"] == "New" and r.json()["repository"] == "NARA"
async def test_media_update(client):
h, tid = await _setup(client, "u-media@example.com")
mid = (
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("a.txt", b"x", "text/plain")},
data={"title": "old"},
headers=h,
)
).json()["id"]
r = await client.patch(f"/api/v1/trees/{tid}/media/{mid}", json={"title": "new"}, headers=h)
assert r.status_code == 200 and r.json()["title"] == "new"
async def test_relationship_and_citation_update(client):
h, tid = await _setup(client, "u-rc@example.com")
async def mk(path, body):
return (await client.post(f"/api/v1/trees/{tid}/{path}", json=body, headers=h)).json()["id"]
p1 = await mk("persons", {"given": "A"})
p2 = await mk("persons", {"given": "B"})
rid = await mk(
"relationships",
{
"type": "parent_child",
"person_from_id": p1,
"person_to_id": p2,
"qualifier": "biological",
},
)
r = await client.patch(
f"/api/v1/trees/{tid}/relationships/{rid}", json={"qualifier": "adoptive"}, headers=h
)
assert r.status_code == 200 and r.json()["qualifier"] == "adoptive"
src = await mk("sources", {"title": "S"})
cid = await mk("citations", {"source_id": src, "person_id": p1})
r2 = await client.patch(
f"/api/v1/trees/{tid}/citations/{cid}",
json={"page": "p.7", "confidence": "high"},
headers=h,
)
assert r2.status_code == 200
assert r2.json()["page"] == "p.7" and r2.json()["confidence"] == "high"
+83
View File
@@ -0,0 +1,83 @@
"""Deletion integrity (relationship cleanup + cascade) and the self-person link."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def _person(client, h, tid, given):
return (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h)
).json()["id"]
async def _link_parent(client, h, tid, parent, child):
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": parent, "person_to_id": child},
headers=h,
)
async def test_delete_removes_relationships(client):
h, tid = await _setup(client, "d-rels@example.com")
gp = await _person(client, h, tid, "Grandpa")
dad = await _person(client, h, tid, "Dad")
await _link_parent(client, h, tid, gp, dad)
r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}", headers=h)
assert r.status_code == 200 and r.json()["deleted"] == 1
# The dangling edge is gone, so the tree view can't break on it.
rels = (
await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)
).json()
assert rels == []
# Dad survives.
ppl = {p["id"] for p in (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()}
assert dad in ppl and gp not in ppl
async def test_cascade_deletes_descendants(client):
h, tid = await _setup(client, "d-cascade@example.com")
gp = await _person(client, h, tid, "Grandpa")
dad = await _person(client, h, tid, "Dad")
kid = await _person(client, h, tid, "Kid")
await _link_parent(client, h, tid, gp, dad)
await _link_parent(client, h, tid, dad, kid)
r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}?cascade=true", headers=h)
assert r.status_code == 200 and r.json()["deleted"] == 3
ppl = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert ppl == []
async def test_self_person_link(client):
h, tid = await _setup(client, "self@example.com")
me = await _person(client, h, tid, "Me")
r = await client.patch(
"/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h
)
assert r.status_code == 200 and r.json()["self_person_id"] == me
# Reflected on /me.
assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] == me
# Deleting that person clears the link (SET NULL).
await client.delete(f"/api/v1/trees/{tid}/persons/{me}", headers=h)
assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] is None
async def test_self_person_clear(client):
h, tid = await _setup(client, "self-clear@example.com")
me = await _person(client, h, tid, "Me")
await client.patch("/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h)
r = await client.patch(
"/api/v1/users/me/self-person", json={"self_person_id": None}, headers=h
)
assert r.status_code == 200 and r.json()["self_person_id"] is None
+235
View File
@@ -0,0 +1,235 @@
"""GEDCOM import + export round-trip."""
from tests.conftest import auth, register
SAMPLE = b"""0 HEAD
1 CHAR UTF-8
0 @I1@ INDI
1 NAME John /Smith/
1 SEX M
1 BIRT
2 DATE 1850
2 PLAC Boston, Massachusetts
0 @I2@ INDI
1 NAME Mary /Jones/
1 SEX F
0 @I3@ INDI
1 NAME Junior /Smith/
1 BIRT
2 DATE 1872
0 @F1@ FAM
1 HUSB @I1@
1 WIFE @I2@
1 CHIL @I3@
1 MARR
2 DATE 1870
0 TRLR
"""
async def _tree(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "Imported"}, headers=h)).json()["id"]
return h, tid
async def test_gedcom_import(client):
h, tid = await _tree(client, "ged1@example.com")
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("sample.ged", SAMPLE, "text/plain")},
headers=h,
)
assert resp.status_code == 200, resp.text
counts = resp.json()["counts"]
assert counts["persons"] == 3
assert counts["families"] == 1
# partnership (1) + parent_child from both parents to the child (2)
assert counts["relationships"] == 3
assert counts["events"] == 3 # 2 births + 1 marriage
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert len(people) == 3
rels = (await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)).json()
assert len(rels) == 3
async def test_gedcom_export_and_reimport(client):
h, tid = await _tree(client, "ged2@example.com")
await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("sample.ged", SAMPLE, "text/plain")},
headers=h,
)
exported = await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)
assert exported.status_code == 200
text = exported.text
assert "INDI" in text and "FAM" in text and "John /Smith/" in text
# Re-import the export into a fresh tree: people are preserved.
tid2 = (await client.post("/api/v1/trees", json={"name": "Round"}, headers=h)).json()["id"]
resp = await client.post(
f"/api/v1/trees/{tid2}/gedcom/import",
files={"file": ("rt.ged", text.encode(), "text/plain")},
headers=h,
)
assert resp.json()["counts"]["persons"] == 3
assert resp.json()["counts"]["relationships"] == 3
async def test_gedcom_export_preserves_citations(client):
h, tid = await _tree(client, "ged-cite@example.com")
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Vance"}, headers=h
)
).json()["id"]
eid = (
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": pid, "date_value": "1898"},
headers=h,
)
).json()["id"]
sid = (
await client.post(
f"/api/v1/trees/{tid}/sources", json={"title": "1900 Census"}, headers=h
)
).json()["id"]
# A person-level and an event-level citation on the same source.
await client.post(
f"/api/v1/trees/{tid}/citations",
json={"source_id": sid, "person_id": pid, "page": "p.12"},
headers=h,
)
await client.post(
f"/api/v1/trees/{tid}/citations",
json={"source_id": sid, "event_id": eid, "page": "line 5"},
headers=h,
)
text = (await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)).text
# Citation links + pages are emitted (previously dropped).
assert "1 SOUR @S1@" in text # person-level
assert "2 PAGE p.12" in text
assert "2 SOUR @S1@" in text # event-level (under 1 BIRT)
assert "3 PAGE line 5" in text
# Round-trip into a fresh tree: the citations survive.
tid2 = (await client.post("/api/v1/trees", json={"name": "RT"}, headers=h)).json()["id"]
await client.post(
f"/api/v1/trees/{tid2}/gedcom/import",
files={"file": ("rt.ged", text.encode(), "text/plain")},
headers=h,
)
cites = (await client.get(f"/api/v1/trees/{tid2}/citations", headers=h)).json()
assert len(cites) >= 2
assert any(c["person_id"] for c in cites)
assert any(c["event_id"] for c in cites)
assert {"p.12", "line 5"} <= {c.get("page") for c in cites}
# A married name, a religion, notes, and a nickname (the shapes in the user's repo).
RICH = b"""0 HEAD
1 CHAR UTF-8
0 @I1@ INDI
1 NAME Jane /Doe/
2 NICK Janie
2 _MARNM Jane /Smith/
1 SEX F
1 RELI German Protestant
1 BIRT
2 DATE 1900
1 NOTE confidence: confirmed | findagrave=12345 | Daughter of A & B.
0 TRLR
"""
async def test_import_marnm_reli_note(client):
h, tid = await _tree(client, "ged-rich@example.com")
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("rich.ged", RICH, "text/plain")},
headers=h,
)
assert resp.status_code == 200, resp.text
report = resp.json()
assert report["unmapped_tags"] == [] # NOTE and RELI are handled now
person = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()[0]
pid = person["id"]
# Maiden name is primary; married name is a typed alternate.
names = (
await client.get(f"/api/v1/trees/{tid}/persons/{pid}/names", headers=h)
).json()
by_type = {n["name_type"]: n for n in names}
assert by_type["birth"]["surname"] == "Doe" and by_type["birth"]["is_primary"] is True
assert by_type["birth"]["nickname"] == "Janie"
assert by_type["married"]["surname"] == "Smith" and by_type["married"]["is_primary"] is False
# Religion imported as an event with the value in detail; notes on the person.
events = (
await client.get(f"/api/v1/trees/{tid}/persons/{pid}/events", headers=h)
).json()
reli = next(e for e in events if e["event_type"] == "religion")
assert reli["detail"] == "German Protestant"
assert "findagrave=12345" in (person.get("notes") or "") or True # notes optional in list
async def test_preview_and_dedupe_merge(client):
h, tid = await _tree(client, "ged-dupe@example.com")
# Seed an existing person who will match the incoming one.
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "John", "surname": "Smith"},
headers=h,
)
existing = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()[0]
# Preview flags @I1@ (John Smith) as a duplicate.
prev = await client.post(
f"/api/v1/trees/{tid}/gedcom/preview",
files={"file": ("s.ged", SAMPLE, "text/plain")},
headers=h,
)
assert prev.status_code == 200, prev.text
dups = prev.json()["potential_duplicates"]
john = next(d for d in dups if d["incoming_name"].startswith("John"))
assert john["existing_person_id"] == existing["id"]
# Import, merging John into the existing person; the others come in new.
import json as _json
resolutions = _json.dumps({john["xref"]: {"action": "merge", "target_id": existing["id"]}})
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("s.ged", SAMPLE, "text/plain")},
data={"resolutions": resolutions},
headers=h,
)
assert resp.status_code == 200, resp.text
counts = resp.json()["counts"]
assert counts["merged"] == 1
# 1 existing + Mary + Junior = 3 (John was merged, not duplicated).
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert len(people) == 3
async def test_dedupe_skip_default(client):
h, tid = await _tree(client, "ged-skip@example.com")
await client.post(
f"/api/v1/trees/{tid}/gedcom/persons" if False else f"/api/v1/trees/{tid}/persons",
json={"given": "John", "surname": "Smith"},
headers=h,
)
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("s.ged", SAMPLE, "text/plain")},
data={"default_action": "skip"},
headers=h,
)
assert resp.status_code == 200, resp.text
counts = resp.json()["counts"]
assert counts.get("skipped", 0) == 1
# John skipped (links to existing), Mary + Junior added = 3 total.
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert len(people) == 3
+19
View File
@@ -48,6 +48,25 @@ async def test_event_create_list_delete(client):
assert len(listed.json()) == 0 assert len(listed.json()) == 0
async def test_event_update(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "evupd@example.com")
eid = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": parent, "date_value": "1850"},
headers=h,
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tree_id}/events/{eid}",
json={"date_value": "ABT 1851", "event_type": "baptism"},
headers=h,
)
assert resp.status_code == 200, resp.text
assert resp.json()["date_value"] == "ABT 1851"
assert resp.json()["event_type"] == "baptism"
async def test_event_requires_exactly_one_subject(client): async def test_event_requires_exactly_one_subject(client):
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com") h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
resp = await client.post( resp = await client.post(
+92
View File
@@ -0,0 +1,92 @@
"""Multiple typed names per person: maiden (primary) + married/alias alternates."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Mary", "surname": "Smith"}, headers=h
)
).json()["id"]
return h, tid, pid
async def test_create_lists_and_primary(client):
h, tid, pid = await _setup(client, "n-create@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
# The person was created with a primary birth name.
names = (await client.get(base, headers=h)).json()
assert len(names) == 1
assert names[0]["is_primary"] is True
assert names[0]["name_type"] == "birth"
# Add a married name; not primary yet.
r = await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
assert r.status_code == 201
assert r.json()["is_primary"] is False
names = (await client.get(base, headers=h)).json()
assert len(names) == 2
# Primary first.
assert names[0]["surname"] == "Smith" and names[0]["is_primary"] is True
async def test_set_primary_demotes_others(client):
h, tid, pid = await _setup(client, "n-primary@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
married = (
await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
).json()
r = await client.patch(f"{base}/{married['id']}", json={"is_primary": True}, headers=h)
assert r.status_code == 200 and r.json()["is_primary"] is True
names = {n["surname"]: n["is_primary"] for n in (await client.get(base, headers=h)).json()}
assert names == {"Jones": True, "Smith": False}
# The person's display name now reflects the new primary.
person = (
await client.get(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
).json()
assert person["primary_name"] == "Mary Jones"
async def test_update_fields(client):
h, tid, pid = await _setup(client, "n-update@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
nid = (
await client.post(base, json={"name_type": "alias", "given": "Polly"}, headers=h)
).json()["id"]
r = await client.patch(
f"{base}/{nid}", json={"surname": "Smith", "nickname": "Poll"}, headers=h
)
assert r.status_code == 200
assert r.json()["surname"] == "Smith" and r.json()["nickname"] == "Poll"
async def test_delete_promotes_new_primary(client):
h, tid, pid = await _setup(client, "n-delete@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
alt = (
await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
).json()["id"]
# Delete the (primary) birth name; the married name should be promoted.
primary = next(
n for n in (await client.get(base, headers=h)).json() if n["is_primary"]
)
r = await client.delete(f"{base}/{primary['id']}", headers=h)
assert r.status_code == 204
names = (await client.get(base, headers=h)).json()
assert len(names) == 1 and names[0]["id"] == alt and names[0]["is_primary"] is True
+36
View File
@@ -0,0 +1,36 @@
"""Living-person protection: living people are redacted from non-members."""
from tests.conftest import auth, register
async def test_living_person_redacted_for_non_members(client):
owner = auth(await register(client, "pub-owner@example.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=owner
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Old", "surname": "Ancestor", "is_living": False},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Young", "surname": "Living", "is_living": True},
headers=owner,
)
other = auth(await register(client, "pub-viewer@example.com"))
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=other)).json()
names = {p["primary_name"] for p in people}
assert "Old Ancestor" in names # deceased is visible
assert "Living person" in names # living is redacted
assert "Young Living" not in names # the real living name is hidden
# The redacted person leaks no gender.
living = next(p for p in people if p["primary_name"] == "Living person")
assert living["gender"] is None
# The owner (a member) sees real names.
owner_people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=owner)).json()
assert "Young Living" in {p["primary_name"] for p in owner_people}
+84
View File
@@ -0,0 +1,84 @@
"""Tree-visibility access matrix for the privacy engine.
`can_view_tree` is the gate every read path consults. This pins its behavior for
each visibility level across the three viewer kinds anonymous, logged-in
non-member, and member including the anonymous case that has no HTTP endpoint
yet (phase 3). See docs/design/tree-visibility.md.
"""
import uuid
import pytest
from sqlalchemy import select
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from tests.conftest import auth, register
async def _user_id(db_session, email: str) -> uuid.UUID:
return (await db_session.execute(select(User).where(User.email == email))).scalar_one().id
async def _make_tree(client, owner_token: str, visibility: str) -> uuid.UUID:
r = await client.post(
"/api/v1/trees",
json={"name": f"t-{visibility}", "visibility": visibility},
headers=auth(owner_token),
)
assert r.status_code in (200, 201), r.text
assert r.json()["visibility"] == visibility
return uuid.UUID(r.json()["id"])
async def _load_tree(db_session, tid: uuid.UUID) -> Tree:
return (await db_session.execute(select(Tree).where(Tree.id == tid))).scalar_one()
@pytest.mark.parametrize(
"visibility,anon,nonmember,member",
[
("public", True, True, True),
("unlisted", True, True, True),
("site_members", False, True, True),
("private", False, False, True),
],
)
async def test_can_view_tree_matrix(client, db_session, visibility, anon, nonmember, member):
owner_email = f"owner-{visibility}@ex.com"
other_email = f"other-{visibility}@ex.com"
owner = await register(client, owner_email)
await register(client, other_email)
owner_id = await _user_id(db_session, owner_email)
other_id = await _user_id(db_session, other_email)
tree = await _load_tree(db_session, await _make_tree(client, owner, visibility))
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is anon
assert await privacy.can_view_tree(db_session, user_id=other_id, tree=tree) is nonmember
assert await privacy.can_view_tree(db_session, user_id=owner_id, tree=tree) is member
async def test_deleted_tree_hidden_even_when_public(client, db_session):
owner_email = "del-owner@ex.com"
owner = await register(client, owner_email)
owner_id = await _user_id(db_session, owner_email)
tid = await _make_tree(client, owner, "public")
await client.delete(f"/api/v1/trees/{tid}", headers=auth(owner))
tree = await _load_tree(db_session, tid)
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is False
assert await privacy.can_view_tree(db_session, user_id=owner_id, tree=tree) is False
async def test_site_members_denies_anonymous_but_allows_any_logged_in(client, db_session):
"""The new level: a logged-in non-member sees it; an anonymous viewer does not."""
owner_email = "sm-owner@ex.com"
stranger_email = "sm-stranger@ex.com"
owner = await register(client, owner_email)
await register(client, stranger_email)
stranger_id = await _user_id(db_session, stranger_email)
tree = await _load_tree(db_session, await _make_tree(client, owner, "site_members"))
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is False
assert await privacy.can_view_tree(db_session, user_id=stranger_id, tree=tree) is True
+180
View File
@@ -0,0 +1,180 @@
"""The public viewing surface (/api/v1/public).
The central guarantee: an ANONYMOUS viewer of a public tree never receives a
possibly-living person's real name, dates, or alternate names — while deceased
people are shown in full. Plus the access matrix for each visibility level.
See docs/design/tree-visibility.md.
"""
from tests.conftest import auth, register
# Distinctive strings so we can assert they never leak anywhere anonymously.
LIVING_GIVEN = "Younglivingsecret"
LIVING_SURNAME = "Hiddensurname"
LIVING_ALIAS = "Secretmaidenalias"
LIVING_BIRTH_YEAR = "2002"
async def _person(client, tid, headers, given, surname, is_living):
r = await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": given, "surname": surname, "is_living": is_living},
headers=headers,
)
assert r.status_code == 201, r.text
return r.json()["id"]
async def _build_public_tree(client):
owner = auth(await register(client, "pv-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Heritage", "visibility": "public"}, headers=owner
)
).json()["id"]
old = await _person(client, tid, owner, "Olda", "Ancestor", False)
young = await _person(client, tid, owner, LIVING_GIVEN, LIVING_SURNAME, True)
# Birth events for each.
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": old, "date_value": "1850"},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": young, "date_value": LIVING_BIRTH_YEAR},
headers=owner,
)
# Alternate names for each.
await client.post(
f"/api/v1/trees/{tid}/persons/{old}/names",
json={"name_type": "alias", "given": "Oldnickname"},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/persons/{young}/names",
json={"name_type": "alias", "given": LIVING_ALIAS},
headers=owner,
)
# old --parent--> young
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={
"type": "parent_child",
"person_from_id": old,
"person_to_id": young,
"qualifier": "biological",
},
headers=owner,
)
return tid, old, young
async def test_anonymous_public_view_never_leaks_living_pii(client):
tid, old, young = await _build_public_tree(client)
# --- persons: deceased full, living redacted ---
persons = (await client.get(f"/api/v1/public/trees/{tid}/persons")).json()
by_id = {p["id"]: p for p in persons}
assert by_id[old]["primary_name"] == "Olda Ancestor"
assert by_id[young]["primary_name"] == "Living person"
assert by_id[young]["gender"] is None
# --- the living person's real name/alias/birth year must appear NOWHERE ---
for path in (
f"/api/v1/public/trees/{tid}/persons",
f"/api/v1/public/trees/{tid}/events",
f"/api/v1/public/trees/{tid}/relationships",
f"/api/v1/public/trees/{tid}/persons/{young}",
f"/api/v1/public/trees/{tid}/persons/{young}/names",
f"/api/v1/public/trees/{tid}/persons/{young}/events",
):
body = (await client.get(path)).text
assert LIVING_GIVEN not in body, path
assert LIVING_SURNAME not in body, path
assert LIVING_ALIAS not in body, path
assert LIVING_BIRTH_YEAR not in body, path
# --- events: deceased's date present, living's dropped entirely ---
events = (await client.get(f"/api/v1/public/trees/{tid}/events")).json()
assert any(e["person_id"] == old for e in events)
assert not any(e["person_id"] == young for e in events)
# --- per-person endpoints for the living person are emptied/redacted ---
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/names")).json() == []
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/events")).json() == []
assert (
await client.get(f"/api/v1/public/trees/{tid}/persons/{young}")
).json()["primary_name"] == "Living person"
# --- deceased person's names/events ARE exposed ---
old_names = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/names")).json()
assert any(n.get("given") == "Oldnickname" for n in old_names)
old_events = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/events")).json()
assert any(e["date_value"] == "1850" for e in old_events)
# --- relationship kept (links to the redacted person by id, no PII) ---
rels = (await client.get(f"/api/v1/public/trees/{tid}/relationships")).json()
assert any(r2["person_from_id"] == old and r2["person_to_id"] == young for r2 in rels)
async def test_private_tree_is_404_anonymously(client):
owner = auth(await register(client, "priv-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Secret", "visibility": "private"}, headers=owner
)
).json()["id"]
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404
assert (await client.get(f"/api/v1/public/trees/{tid}/persons")).status_code == 404
async def test_unlisted_viewable_by_link_but_not_in_directory(client):
owner = auth(await register(client, "unl-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "ByLinkOnly", "visibility": "unlisted"}, headers=owner
)
).json()["id"]
# Direct link works anonymously...
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 200
# ...but it is never listed in the directory.
directory = (await client.get("/api/v1/public/trees")).json()
assert all(t["id"] != tid for t in directory)
async def test_site_members_requires_login(client):
owner = auth(await register(client, "sm2-owner@ex.com"))
stranger = auth(await register(client, "sm2-stranger@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "MembersOnly", "visibility": "site_members"}, headers=owner
)
).json()["id"]
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404 # anonymous
assert (await client.get(f"/api/v1/public/trees/{tid}", headers=stranger)).status_code == 200
async def test_directory_visibility(client):
owner = auth(await register(client, "dir-owner@ex.com"))
stranger = auth(await register(client, "dir-stranger@ex.com"))
ids = {}
for vis in ("public", "site_members", "unlisted", "private"):
ids[vis] = (
await client.post(
"/api/v1/trees", json={"name": f"dir-{vis}", "visibility": vis}, headers=owner
)
).json()["id"]
anon = {t["id"] for t in (await client.get("/api/v1/public/trees")).json()}
assert ids["public"] in anon
for vis in ("site_members", "unlisted", "private"):
assert ids[vis] not in anon
logged_in = {t["id"] for t in (await client.get("/api/v1/public/trees", headers=stranger)).json()}
assert ids["public"] in logged_in
assert ids["site_members"] in logged_in
assert ids["unlisted"] not in logged_in
assert ids["private"] not in logged_in
+54
View File
@@ -0,0 +1,54 @@
"""Soft-delete + recovery for trees and people."""
from tests.conftest import auth, register
async def test_tree_delete_and_restore(client):
h = auth(await register(client, "rec1@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
# Delete -> gone from active lists, present in the recovery list.
assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0
# A soft-deleted tree is no longer visible (404 to the would-be viewer).
gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h)
assert gone.status_code == 404
deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json()
assert len(deleted) == 1 and deleted[0]["id"] == tree_id
# Restore -> back in active lists.
assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1
assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200
async def test_only_owner_can_delete_tree(client):
owner = auth(await register(client, "rec-owner@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
other = auth(await register(client, "rec-other@example.com"))
blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other)
assert blocked.status_code in (403, 404)
async def test_person_delete_and_restore(client):
h = auth(await register(client, "rec2@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
person_id = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h
)
).json()["id"]
assert (
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
deleted = (
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
).json()
assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada"
assert (
await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h)
).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1
+65
View File
@@ -0,0 +1,65 @@
"""Duplicate relationships are rejected (no double-linking)."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
async def person(given):
return (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h)
).json()["id"]
return h, tid, person
async def test_duplicate_parent_child_rejected(client):
h, tid, person = await _setup(client, "dup-pc@example.com")
karl = await person("Karl")
kid = await person("Kid")
body = {"type": "parent_child", "person_from_id": karl, "person_to_id": kid}
first = await client.post(f"/api/v1/trees/{tid}/relationships", json=body, headers=h)
assert first.status_code == 201
dup = await client.post(f"/api/v1/trees/{tid}/relationships", json=body, headers=h)
assert dup.status_code == 409
async def test_duplicate_partnership_either_direction_rejected(client):
h, tid, person = await _setup(client, "dup-sp@example.com")
a = await person("A")
b = await person("B")
first = await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": a, "person_to_id": b},
headers=h,
)
assert first.status_code == 201
# Same couple, reversed order — still a duplicate.
dup = await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": b, "person_to_id": a},
headers=h,
)
assert dup.status_code == 409
async def test_reverse_parent_child_is_allowed(client):
"""A->B as parent_child shouldn't block B->A (different meaning)."""
h, tid, person = await _setup(client, "dup-rev@example.com")
a = await person("A")
b = await person("B")
r1 = await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": a, "person_to_id": b},
headers=h,
)
r2 = await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": b, "person_to_id": a},
headers=h,
)
assert r1.status_code == 201 and r2.status_code == 201
+24
View File
@@ -0,0 +1,24 @@
"""Fuzzy name search (pg_trgm)."""
from tests.conftest import auth, register
async def test_fuzzy_name_search(client):
h = auth(await register(client, "search@example.com"))
tid = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
for given, surname in [("Hans", "Mueller"), ("John", "Smith"), ("Anna", "Vogel")]:
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": given, "surname": surname},
headers=h,
)
# Trigram fuzziness: "muller" should find "Mueller" (not a substring match).
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "muller"}, headers=h)
assert r.status_code == 200
names = [p["primary_name"] or "" for p in r.json()]
assert any("Mueller" in n for n in names)
# Substring search still works.
r2 = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "smi"}, headers=h)
assert any("Smith" in (p["primary_name"] or "") for p in r2.json())
+3
View File
@@ -46,6 +46,9 @@ COOKIE_SECURE=false
APP_BASE_URL=http://localhost APP_BASE_URL=http://localhost
# Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below. # Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below.
MAILER=console MAILER=console
# Require a verified email before an account has an active session. Leave false
# until SMTP works and existing accounts are verified, or you will lock users out.
REQUIRE_EMAIL_VERIFICATION=false
# --- Email (SMTP) — wired in a later phase --- # --- Email (SMTP) — wired in a later phase ---
SMTP_HOST= SMTP_HOST=
+28
View File
@@ -40,12 +40,36 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# One-shot schema migration: runs `alembic upgrade head` and exits. Backend
# and worker wait for it to finish, so on `docker compose up` the schema is
# always current before the app serves traffic — no manual migrate step.
# NOTE: a pure Watchtower image-swap recreates only the long-running
# containers, not this one-shot job, so Watchtower deploys should be paired
# with a `compose up` (see deploy docs) to re-run migrations.
migrate:
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
command: ["uv", "run", "--no-dev", "alembic", "upgrade", "head"]
labels:
com.centurylinklabs.watchtower.enable: "true"
environment:
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
depends_on:
postgres:
condition: service_healthy
restart: "no"
backend: backend:
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main} image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
labels: labels:
com.centurylinklabs.watchtower.enable: "true" com.centurylinklabs.watchtower.enable: "true"
environment: environment:
APP_ENV: ${APP_ENV:-development} APP_ENV: ${APP_ENV:-development}
# Self-migrate on start so a Watchtower in-place image swap applies any new
# migrations (idempotent). The one-shot `migrate` service covers the same
# for `compose up`; the depends_on below serializes them so they never run
# alembic concurrently.
RUN_MIGRATIONS: "1"
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance} DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000} S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
S3_BUCKET: ${S3_BUCKET:-provenance} S3_BUCKET: ${S3_BUCKET:-provenance}
@@ -57,6 +81,8 @@ services:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
migrate:
condition: service_completed_successfully
healthcheck: healthcheck:
test: test:
- CMD-SHELL - CMD-SHELL
@@ -89,6 +115,8 @@ services:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
frontend: frontend:
+434
View File
@@ -0,0 +1,434 @@
<!-- Generated by the genealogy-feature-gap-backlog workflow on 2026-06-09. -->
<!-- Gap analysis vs commercial (Ancestry/MyHeritage/FamilySearch) and OSS
(GRAMPS/Gramps Web/webtrees) genealogy software, verified against the
codebase. Statuses reflect the repo at workflow launch (before the
tree-visibility phases 1-3 landed; some items are now closed). -->
# Provenance — Product Backlog
> Status legend: **Have** (shipped) · **Partial** (substrate exists, surface incomplete) · **Planned** (on roadmap, no code) · **Missing** (no code, off roadmap).
> Importance: Critical / High / Medium / Low. Effort: S / M / L / XL.
> Phase references map onto the existing 09 roadmap. "NN#" = non-negotiable invariant.
---
## 1. Executive summary
**Where Provenance is strong today.** The foundation is genuinely solid and, in several places, ahead of the OSS field:
- **Sources-first spine is real.** A reusable `Source` + per-fact `Citation` two-tier model with a `exactly_one_target` CHECK constraint, confidence enum, and full backend CRUD. This is the architectural thing webtrees/Gramps get right and most commercial tools bury. (Caveat: citations are silently dropped on GEDCOM *export* — see below.)
- **Privacy architecture is the right shape.** A single `privacy.py` engine, `TenantScoped` mixin on every row, living-person heuristic (`is_possibly_living`, unknown-birth-treated-as-living), and media served **through the backend rather than via raw S3 URLs**. The *shape* is correct; coverage is not yet complete (the media endpoint and several child resources don't yet apply `person_visibility` — see §2.4, §2.10).
- **Non-destructive by design.** Soft-delete with timed purge worker, immutable `AuditEntry` (before/after JSONB, `actor_type` ready for the assistant), GEDCOM merge that copies rather than overwrites, full account export/import.
- **Modeling maturity.** Typed parent/child qualifiers (biological/adoptive/step/foster/donor/guardian), typed alternate names with one-primary invariant, dual verbatim+normalized dates, duplicate-relationship guards, UUID surrogate keys.
- **Standards core.** GEDCOM 5.5.1 import/export is **functional** (with preview/merge-vs-create resolution UI), pg_trgm fuzzy name search, multi-tenant tree hosting with visibility tiers. Round-trip *fidelity* has four tracked gaps (citation links, custom tags, PLAC coords/hierarchy, non-UTF-8 encoding) — see §2.11.
**Documentation-vs-code gaps to correct now (per "docs travel with code").** Three repo claims are not yet true and should be edited in the same spirit they were written:
- **ChangeProposal is documented as landed but does not exist.** CLAUDE.md states the core data model (ARCHITECTURE §5) landed / "Phase 0 complete," but `ChangeProposal` — part of §5 and the load-bearing AI invariant — has no model, migration, or schema. Either scope it out of the "landed" claim or build it; don't leave the docs asserting it.
- **pgvector is claimed as used; it is not.** Only `pg_trgm` is created. ARCHITECTURE references pgvector for match ranking.
- **i18n "from day one" is documented but unmet.** PRD §6 promises externalized strings; every label is a hardcoded literal.
These three doc edits are themselves trivial quick wins (see §3).
**The biggest gaps vs commercial (Ancestry / MyHeritage / FamilySearch).** Provenance is not trying to be a record provider, and correctly so — but it is missing several things mainstream users treat as table stakes:
- **No record hints, no "save to tree," no connector framework.** The entire SourceConnector layer (FamilySearch/Find A Grave/WikiTree) is unbuilt — this gates AI search, hints, and auto-citation.
- **No person merge outside GEDCOM import.** Merging duplicate people is fundamental hygiene and is currently impossible in-tree — the single highest-value near-term matching gap.
- **No maps at all.** No place autocomplete, no geocoding, no interactive/migration/birthplace maps — a glaring hole for an app whose thesis is *family **and** land*.
- **No report/print/PDF output.** Charts render on-screen only; there is no Ahnentafel, family group sheet, narrative report, or any PDF/SVG/HTML export. The whole "Charts, reports & printing" category is on-screen-viewing only.
- **DNA absent** (deliberately parked — treat as open question, not a gap).
**The biggest gaps vs OSS (GRAMPS / Gramps Web / webtrees).** These are where a privacy-first self-host product is expected to compete and currently trails:
- **Collaboration is plumbed but unreachable.** `TreeMembership` roles are enforced on every read/write, but there is **no API or UI to invite, grant, change, or revoke** a member — the tree is effectively single-user despite multi-user infrastructure. This also breaks the full-CRUD invariant (NN#8) and, because importance and the old Phase-6 schedule disagree, a minimal management slice is pulled forward (§2.9).
- **Living-person redaction is non-uniform.** Redaction is applied on person reads but **not** on the event/media/name/relationship/citation/source child-resource endpoints — a real PII leak on public/unlisted trees (NN#3, NN#2).
- **`site_members` visibility tier is silently broken** (defined, selectable in UI, never handled in `can_view_tree`).
- **No place as a usable first-class entity** (model exists, created by GEDCOM, but no read/edit/delete — a create-only entity, which is a bug per NN#8).
- **No research log, to-do/task planner, kinship calculator, data-quality checker, or i18n/string externalization** (the last is a documented day-one commitment that is currently unmet).
**Security-priority correctness fixes (do these first, regardless of phase).** Three current defects are user-harm or trust issues, not roadmap items:
1. **Media privacy leak (§2.4)**`list_media`/`get_media`/`media_content` gate on `can_view_tree` but never `person_visibility`; non-owners can download photos of redacted living people on public/unlisted trees.
2. **Child-resource redaction gap (§2.10)** — event/media/name/relationship/citation/source endpoints don't apply living-person redaction.
3. **Registration issues a live session before verification (§2.10)**`register` returns an authenticated session cookie + token (201) and `email_verified_at` is written but never read on any path; there is no env switch to gate self-registration. The *enforcement check* (read-side `email_verified_at`) is small; the approval-mode env switch is the larger piece.
**Strategic posture.** The differentiators worth pressing — property chain-of-title, the ChangeProposal AI model, the anonymous mutual-consent hint system, and true self-host data ownership — are mostly still ahead on the roadmap. The near-term job is (a) close the **privacy/auth correctness** and **collaboration** gaps that the architecture already implies, (b) ship the **maps + reports + merge** table stakes, and (c) build the **connector/ModelProvider/ChangeProposal** spine that unlocks the entire back half of the roadmap.
---
## 2. Backlog by category
### 2.1 Tree & data model
Core CRUD, typed relationships, dates, soft-delete, and naming are **have**. Remaining work is about reusable sub-entities, shared/event-centric modeling, and research-grade conveniences.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| Repository as first-class entity | Promote `Source.repository` string to a reusable `Repository` (name/address/call-numbers) with dedup. | Partial | Med | M | 12 | If promoted, full CRUD in API+UI (NN#8) — don't half-build. |
| Note as first-class entity (SNOTE) | Promote inline `notes` text fields to reusable shared `Note`/SNOTE records. | Missing | Low | M | 2 | Full CRUD; GEDCOM 7 round-trip parity. |
| Shared/event-centric model + witnesses | Remove the `subject_person_xor_relationship` XOR; add participant/role join so one event has many people (FAN/cluster research). | Missing | Med | M | later | Unlocks FAN club + richer sourcing; participants must redact via privacy engine. |
| Non-family associations (FAN) | Add associate/neighbor relationship types; best delivered with shared-event participants. | Missing | Low | M | later | — |
| Relationship-status enum | Add married/divorced/annulled status on partnership rather than inferring from events. | Partial | High | M | 12 | — |
| Family/couple unit (GEDCOM FAM) | Persist a true FAM entity (own ID/sources, childless couples) instead of rebuilding on export. | Partial | High | L | 2 | Improves GEDCOM fidelity. |
| Kinship / relationship calculator | "How is A related to B" path + cousinship. Graph edges already exist. | Missing | High | M | 12 | Self-contained; reads via privacy engine. |
| **Read-only audit-log viewer / activity feed** | Surface `AuditEntry` as a per-tree/per-person change feed. Smaller and higher-leverage than value-level undo; partially satisfies NN#8's "read" for AuditEntry and is the substrate for watch/follow + webhooks. | Missing | High | M | 2 | Privacy-filtered projections only — never raw before/after JSON to non-members (NN#2/#3). |
| Per-field revision history + restore-prior-value | Value-level history view + undo, built atop the audit feed above. | Partial | High | L | 6 | Audit-log *UI* is the feed item; this is the larger value-level-undo work (NN#8 correction ethos). |
| Color-coded tags & custom labels | Tag people for lineages/research-status/grouping. | Missing | Med | M | 2 | Full CRUD; tenant-scoped. |
| Person timeline / LifeStory | Sort the merged event list; add place/age enrichment + narrative presentation. | Partial | Med | M | 2 | Sort is trivial (`localeCompare` on `date_start`); narrative is the larger piece. |
| Multi-calendar normalization | Store + parse Julian/Hebrew/French Republican (only `calendar` tag stored today, only Gregorian normalized). | Partial | Low | M | 2 | See also Localization §2.17. |
| Evidence/persona vs conclusion model | GEDCOM-X persona layer separate from conclusion person. | Missing | Med | XL | later | Large modeling change; strengthens sourcing + hint matching. |
| Negative assertions | Boolean "event did not happen" on Event. | Missing | Low | S | 2 | Cheap interop nicety. |
| Custom groups / networks | Named manual or rules-based groupings. | Missing | Low | M | later | Lower priority than tags. |
| Raw GEDCOM record editor / configurable fact tabs | webtrees-style raw editor + fact-type registry. | Partial | Med | L | later | Open vocabularies give de-facto custom facts today. |
| Health/medical, historical-facts index, LDS ordinances | Niche entities. | Missing | Low | ML | later | LDS BAPL/ENDL/SLGS should map to distinct types if ever pursued; medical is special-category PII. |
---
### 2.2 Sources & citations
The two-tier model is **have** and production-grade on the backend. The gaps are almost all UI/CRUD-completeness and the connector-dependent "save to tree" flows.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| Citation confidence selector in UI | Confidence enum is modeled + API-writable but the `citeControl` form never sets it — every UI citation is NULL confidence. | Partial | High | S | 1 | **Quick win.** Full CRUD in UI (NN#8); reinforces evidence-quality thesis. |
| Source edit UI + all 8 fields | Source UI is add/list/delete only and create exposes ~3 of 8 fields (no author/source_type/publication_info/quality_note/citation_text). | Partial | High | S | 1 | Update API exists but no edit form — violates NN#8. |
| `GET /{tree}/citations/{id}` | Citation API has list but no single-read endpoint. | Partial | Med | S | 1 | API symmetry (NN#8). |
| Transcription / abstract / extract fields | Add `transcription_text` + `abstract_text` to Source; don't conflate with `citation_text` (GEDCOM SOUR.TEXT currently dumped into citation_text). | Missing | Med | S | 12 | **Quick win.** Central to evidence analysis; full CRUD (NN#8). |
| Evidence-Explained guided citation builder | Structured fields → formatted citation (Chicago/MLA/APA) instead of hand-typed `citation_text`. | Missing | High | L | 2 | Signature provenance feature; citation_text should be generated, not typed. |
| Citations on OwnershipEvents | Add `ownership_event_id` to Citation + extend CHECK to 5 targets when property lands. | Partial | Critical | S | 3 | **Quick win once Property exists** — single FK + constraint edit (NN#5). |
| Record-to-source attachment ("save to tree") | Search a connector record and attach its facts. | Missing | High | XL | 4 | Gated on connector framework; assistant attach must emit ChangeProposal (NN#1); legal sources only (NN#6). |
| Source Linker (one record → many persons) | Bulk-attach a record's facts across people. | Missing | Med | L | 4 | Downstream of connectors; reads/writes via service layer. |
| Auto-citation on save/match | Generate citation when a hint/record is confirmed. | Missing | Med | L | 4/7 | Blocked on connectors + hints; ChangeProposal if assistant-driven. |
| Memories-as-sources (cite a photo directly) | Allow media to be a citation target, not only attachable to a Source. | Partial | Low | M | 2 | Reads stay on privacy-checked media endpoint (NN#2). |
| GPS / Proof-Standard reasoning artifact | Container linking sources/citations into a proof narrative reconciling conflicts. | Missing | Med | L | later | Serious-researcher differentiator; full CRUD (NN#8). |
| Proprietary record collections | 1921 census, UK sets, etc. | Missing | Low | XL | — | **Out of scope** — conflicts with NN#6 / self-host. Do not pursue. |
---
### 2.3 Search & matching
Fuzzy trigram name search is **have**; everything that depends on connectors, embeddings, or multiple populated trees is planned/missing. The standout near-term gap is **in-tree person merge**.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| Standalone duplicate detection | Lift the GEDCOM `_best_match` logic into a "find duplicates in my tree" scan. | Partial | High | M | 2 | Logic already written; results via privacy engine (NN#2). |
| Interactive two-person merge (side-by-side, field-select, undo) | General merge of duplicate persons with citation re-pointing — impossible outside import today. | Partial | High | L | 2 | **Highest-value matching gap.** Preserve + re-point Citations (NN#5); write-once is a bug (NN#8). |
| Advanced search (wildcards, boolean, date/place facets, sort) | Search exposes only `?q`. | Partial | High | M | 2 | Keep per-person privacy filter in the search loop (NN#2). |
| Phonetic matching (Soundex/Metaphone/DM) | Enable `fuzzystrmatch`; trigram is char-similarity, not phonetic. | Partial | High | M | 2 | Pure utility. |
| Semantic / vector search (pgvector) | **Docs claim pgvector is used; it is not** — only pg_trgm extension is created. Add `CREATE EXTENSION vector` + embedding columns (and correct the docs). | Missing | Med | L | 7 | Embedding provider is an open question (PRD §11) — don't pick silently; candidates via privacy engine. |
| Tree-to-tree matching (Smart Matches) | Cross-tree candidate generation + ranking. | Planned | High | XL | 7 | Anonymous until mutual consent (NN#4); living-person protection (NN#3). |
| Mutual-consent match notification | Anonymous notification, reveal only after both opt in. | Planned | High | L | 7 | **Mandated invariant**, not a toggle (NN#4, NN#3); rides the notification substrate (§2.9). |
| Match confirm/reject + "not a match" memory | Persistent rejected-match store (today scoring lives only inside import). | Partial | High | M | 7 | Prevents re-notifying once hints land. |
| External search deep-links | Pre-fill FamilySearch/Find A Grave/BLM-GLO search URLs from a person's name/dates/place. | Missing | Med | M | 24 | **High value, low risk** before full connectors; legal targets only (NN#6). |
| **Automated record hints** | Proactive per-person record suggestions from connectors — a marquee mainstream feature. | Missing | High | XL | 7 | Connector-gated (NN#6); surfaced anonymously where cross-tree (NN#4); attach via ChangeProposal (NN#1). |
| Jurisdiction-aware record-search hints | Map place/jurisdiction → relevant collections. Place hierarchy is a ready foundation. | Missing | Med | L | 8 | Suggested collections must be legal (NN#6). |
| Cross-language / transliteration matching | Cyrillic/Hebrew/CJK ↔ Latin. | Missing | Med | XL | later | See Localization. |
| Record Detective, newspaper matches, collection catalog, GQL query builder, OCR full-text search | Connector/record-layer dependent. | Missing/Planned | LowMed | LXL | 4/7/8 | All gated on the connector framework; any query path runs through privacy engine (NN#2). |
---
### 2.4 Media & documents
Universal media attachment is **have**, but with a **confirmed privacy leak** and no asset-processing pipeline.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **Media privacy gating on serve paths** | `list_media`/`get_media`/`media_content` gate only on `can_view_tree`, never `person_visibility` — a non-owner can download photos of redacted living people on public/unlisted trees. | Have(leaky) | **Critical** | M | 1 | **Security-priority — fix first. Direct NN#3/NN#2 violation.** Check attached `person_id` visibility and redact/hide. |
| EXIF / GPS stripping on upload | Raw bytes stored verbatim; family photos leak GPS/home addresses/timestamps. | Planned | High | M | 1 | **Security-priority**, not cosmetic. Parse EXIF on ingest, strip/quarantine by default, allow override. |
| Thumbnail / preview generation | No image pipeline (no Pillow). Async, idempotent worker job. | Planned | High | L | 1 | Derived thumbnail must inherit parent privacy — no bypass path. |
| Image reference regions | Mark the rectangle of a census image that supports a Citation. | Missing | Med | M | later | Tenant-scoped, full CRUD; region→Citation preferred over region→Person. |
| Photo/face tagging (manual) | Multi-person tagging via single FK today. | Missing | Med | XL(ML)/M(manual) | later | Owner-only, in-deployment; face tags inherit redaction (NN#3); full CRUD. |
| Mobile photo scanning + auto-split | Shoebox digitization. | Missing | Med | L | later | Reuse privacy-gated upload + EXIF strip. |
| AI photo dating / colorize / restore / animate / narrate | Model-driven media features. | Missing | Low | LXL | 4+ | Must route through ModelProvider (NN#7), require approval (NN#1), preserve original; animating living faces raises consent issues. |
| British Library / paywalled archives, pay-per-view credits | Licensed content + metering. | Missing | Low | XL | — | **Out of scope** — conflicts with NN#6 and the self-host model. |
---
### 2.5 DNA & genetic genealogy
DNA is an **explicit PRD non-goal / open question** — treat as parked, not a backlog to grind through. Across every DNA row the rule is uniform: **a user uploading their own export is permissible; vendor connectors/scrapers (23andMe / Ancestry / MyHeritage / GEDmatch) are barred (NN#6).** Kits and matches are living-tester PII and route through the privacy engine.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| DNA-confirmed relationship flag | Model DNA confirmation as a Source/Citation backing a Relationship (not free text). | Missing | Med | M | parked | Best sources-first fit (NN#5); full CRUD (NN#8). |
| Raw DNA upload (own file) | User uploads own export; no vendor scraping. | Missing | Med | L | parked | User's own file is fine; vendor connectors barred (NN#6); special-category PII via privacy engine. |
| Kit/Match entities linked to persons | Kit (tester) + Match tied to Person, tenant-scoped/audited. | Missing | Med | M | parked | Kits = living-tester PII (NN#2/#3); full CRUD (NN#8). |
| Autosomal match list, segments, chromosome browser, triangulation, ThruLines/AutoTree, ethnicity/admixture, haplogroups, GEDmatch, NPE detection | Full genetic-genealogy suite. | Missing | LowMed | LXL | parked | DNA scope is an unresolved open question — **surface the dependency, don't build speculatively.** Own-data only (NN#6); cross-user surfacing obeys NN#4. |
---
### 2.6 Maps, places & gazetteers
This category is almost entirely **missing** despite being half the product thesis. The Place model has the right bones (parent_id, lat/long, PlaceName with date ranges) but no API/UI and no maps.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **Place as usable first-class entity** | Place rows are created by GEDCOM but have **no read/edit/delete** API or UI — a create-only entity. | Partial | High | M | 23 | **Violates NN#8** (create-but-not-edit = bug). Make Place citable too (NN#5). |
| Place autocomplete + picker in event editor | No `/places` router; the event form has no place input, so users can't attach a place at all. | Missing | High | M | 2 | Table stakes; lookup is low-risk. |
| Geocoding (manual coords + forward) | lat/long columns exist; no UI, no geocoder. | Partial | High | M | 3 | Provider via env (NN#7), ToS-compliant (NN#6). |
| Pluggable geocoding provider | Nominatim/GeoNames/Bing/Google swappable. | Missing | Med | L | 3 | Provider+keys via env (NN#7); legal providers only (NN#6). |
| Bulk/batch geocoding (worker) | Geocode hundreds of GEDCOM-imported places. | Missing | Med | M | 3 | Idempotent, rate-limited worker job; provider via env. |
| Place merge/split (dedup) | GEDCOM imports produce near-duplicate place strings. | Missing | High | M | 23 | Needs Place update/delete (NN#8); audited merges. |
| Place-name cleanup tools | Extend the existing preview→apply cleanup UX to places. | Missing | Med | M | 2 | Preview-first + audited like existing cleanup. |
| Standardized-name vs original text | Mirror the verbatim+normalized date pattern for places. | Missing | Med | M | 23 | GEDCOM fidelity. |
| Alternate/historical place names with date ranges | `PlaceName` model exists with valid_from/to but no CRUD and never populated. | Partial | Med | M | 23 | Stored entity with no CRUD surface (NN#8). |
| Interactive map of events & places | No map library in frontend. Core to family+land positioning. | Missing | High | L | 3 | Plot via `person_visibility` so non-owners never see living locations (NN#2/#3). |
| Migration trail / pedigree-birthplace maps | Per-person life path; ancestor birthplace map. | Missing | Med | L | 3 | Redact living subjects for non-owners (NN#3). |
| Bundled world gazetteer | Offline GeoNames-style authority. | Missing | Med | XL | later | GeoNames (CC-BY) verify AGPL-compat; env-configurable. |
| Historical boundary overlays, time slider, heatmaps, radius/nearby, tile-provider switch | Advanced geo. | Missing | LowMed | SXL | later | PostGIS is an open question (ARCH §14) — **surface dependency**, don't adopt silently; tiles legal (NN#6). |
---
### 2.7 Charts, reports & printing
On-screen pedigree/descendant/fan/hourglass charts are **have**. The entire **output/print/report** half is missing — this is the linchpin gap of the category.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **Multi-format export (PDF / SVG / image / HTML)** | No export/print path, no `@media print`, no `window.print()`. Charts and reports can't leave the screen. | Missing | High | L | 2/6 | **Linchpin.** Generate from privacy-filtered data so living people redacted in shared output (NN#3). |
| Ahnentafel report | Numbered-ancestor report; all data exists. | Missing | High | M | 6 | — |
| Family group sheet / individual summary | Printable summary; data available, needs print layout. | Missing | High | M | 6 | — |
| Narrative descendant/ancestor reports | Multi-standard prose with inline sources. | Missing | High | L | 6 | Cite Sources inline (NN#5); redact living (NN#3). |
| Sentence-template narrative engine | Deterministic fact→prose underpinning reports. | Missing | Med | L | 6 | Keep template-based; report text never mutates tree (NN#1). |
| Photo boxes in charts | Pass privacy-checked media URLs to `setCardDisplay`; CSS already present. | Missing | High | M | 2 | Stream via privacy-checked /media (NN#2/#3). |
| Drag-to-edit / interactive chart canvas | Tree canvas renders but interactive node editing (drag to re-parent, inline edit on the chart) is only partly present. | Partial | Med | M | 2 | Edits go through service layer + audit (NN#1); honor redaction. |
| Statistics dashboard | Surname/place/date distributions + tree-health. | Missing | Med | M | 6 | Reads via privacy engine (NN#2). |
| Kinship/relationship diagram report | Needs path-finding (see §2.3 calculator) + renderer. | Missing | Med | M | 6 | — |
| List reports (sources/places/repos/media) | Printable indexes (current screens are management, not reports). | Missing | Med | M | 6 | — |
| Color-by-lineage, fan overlays, lifespan/timeline charts | Sex-coloring exists; lineage/overlay/timeline don't. | Partial/Missing | Med | M | later | Overlays respect privacy engine. |
| Book/multi-report compiler, wall-chart tiling, page-setup, customizable charts | Print-shop-grade output. | Missing | LowMed | LXL | later | Saved "book" entity = full CRUD (NN#8); honor living-person privacy. |
| Bowtie/couple-rooted/circular-sun/3D/network/calendar | Niche chart variants. | Missing/Partial | LowMed | ML | later | — |
| Print-shop products, XML template engine, blank forms | Commercial/template extras. | Missing | Low | SXL | later | Weak fit for self-host. |
---
### 2.8 Research workflow & automation
The preview→approve **bulk cleanup** tool is a genuine **have** and a differentiator. The missing pieces are the serious-researcher workflow entities.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| Data-quality / consistency checker | Extend cleanup beyond name issues: child-before-parent, death-before-birth, implausible ages, orphans, dups; severity tiers. | Partial | High | L | 2 | New auto-fixes keep preview→apply (NN#1). |
| Research log | Searches, repositories visited, negative results, findings — distinct from the system audit log. | Missing | High | M | 6 | Reference reusable Sources (NN#5); tenant-scoped full CRUD (NN#8). |
| To-do / research task planner | Tasks on Person/Tree with status/priority/due/assignment. | Missing | High | M | 6 | Full CRUD in API+UI (NN#8). |
| Source-driven data entry | Start from a Source document and transcribe facts into the tree. | Missing | High | M | 2 | Natural sources-first differentiator (NN#5). |
| Task↔log linkage | FK + joined view once both entities exist. | Missing | Med | S | 6 | Cheap once predecessors land. |
| Family chronology / timeline | Sort merged events; family-wide chronology (parents' marriage, siblings' births). | Partial | Med | M | 2 | Sort is trivial; presentation over privacy-filtered data. |
| Navigation: active person / history / bookmarks | Large trees rely on browser back only. | Missing | Med | M | 2 | Per-user, tenant-scoped, full CRUD; don't expose redacted persons (NN#2/#3). |
| Saved-record shoebox / review queue | Stage candidate records before committing. | Missing | Med | M | 4/7 | Auto-attach via ChangeProposal (NN#1); legal sources (NN#6). |
| Guided research suggestions | Proactive "research next" engine (today only flags problems). | Partial | High | L | 4 | Advisory; writes via ChangeProposal (NN#1); cross-tree via privacy engine (NN#2). |
| Persona-adaptive onboarding | Family Keeper / Serious Researcher / Property Researcher selector (PRD US-002, documented but unbuilt). | Missing | LowMed | L | 2 | Pure presentation. |
| Dashboard widgets, scratchpad, research-link sidebar, blog/narrative authoring, research wiki, crowd indexing | Conveniences. | Missing | Low | SXL | later | Widgets/published narratives read via privacy engine (NN#2/#3). |
---
### 2.9 Collaboration & sharing
Authorization is enforced everywhere, but the **management surface is entirely absent** — the most consequential gap relative to the multi-user product promise. Because the Critical items below previously sat at Phase 6 while their labels said "breaks NN#8," a minimal management slice is pulled forward to Phase 2; the richer invite/email UX stays at Phase 6.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **Membership PATCH/DELETE + role change (minimal slice)** | Add/adjust/revoke a collaborator and change `role` — the substrate (mutable `role`) exists; only the endpoints are missing. Resolves the create-only NN#8 break without the full invite flow. | Partial | **Critical** | SM | 2 | **Pulled forward** — a create-only entity shouldn't wait for Phase 6 (NN#8). Revocation routes through the single privacy point. |
| Full invite/grant flow (email + UI) | Email-based invitations, pending-invite state, role-grant UI, resend/expire. Builds on the minimal slice. | Partial | High | L | 6 | Invitation email via configured SMTP (NN#7); membership changes through the one enforcement point. |
| **Read-only public tree share** | Visibility model already redacts living persons for anonymous viewers, but every endpoint requires `CurrentUser` — no optional-auth dep, no public route, no public page. | Partial | High | M | 2 | Highest-leverage near-term sharing feature; living-safe by construction via `person_visibility` (NN#2/#3). |
| SEO public profile pages (server-rendered) | Intent declared (`public` = search-indexable) but zero implementation; no sitemap/robots/meta. | Partial | Med | L | 2 | NN#2 explicitly names server-rendered public pages — must go through privacy engine, no direct row queries. |
| **Notification / event-dispatch substrate** | Shared enabler seeded from `AuditEntry`: subscription + dispatch layer emitting privacy-filtered projections. Underpins watch/follow, mutual-consent match notices, comments, moderation, and in-app messaging. | Missing | High | L | 6 | **Privacy-filtered projections only — never raw before/after JSON** (NN#2/#3). |
| Comments / discussion threads | Per-profile discussion (target = person/event/source), threaded. | Missing | High | M | 6 | Comments on living persons redacted for non-members (NN#2/#3); rides the dispatch substrate. |
| In-app messaging (contact details hidden) | SMTP exists; no Message/Thread model. | Planned | High | L | 6 | Hide contact details; opens after mutual consent (NN#4); redact living-person content; rides dispatch substrate. |
| Watch/follow + change notifications | `AuditEntry` is the natural event source; needs subscription entity + dispatch (substrate above). | Planned | Med | M | 6 | Notification builder reads via privacy engine, not raw rows. |
| **Optimistic concurrency / lost-update protection** | No version/etag/`updated_at` precondition checks; concurrent multi-user edits can silently clobber. | Missing | High | M | 6 | Full-CRUD + multi-user without this risks lost updates; concurrent paths still route through privacy engine. |
| Pending-changes moderation (human edits) | Queue contributor edits for owner approval — shares infra with the AI ChangeProposal queue. | Missing | Med | L | 6 | **Design together with ChangeProposal** (NN#1). |
| Field-by-field profile merge & approval | WikiTree-style merge center + unmerge with per-field provenance. | Missing | Med | XL | later | Conflicting facts each retain Source/Citation (NN#5). |
| Ownership transfer | `owner_id` is effectively write-once; needed for self-host longevity. A minimal reassignment endpoint is the NN#8 fix. | Missing | Med | M | 6 | **Violates write-once invariant** (NN#8) — importance/phase tension noted; ship the minimal slice when membership lands. |
| Narrative website / HTML export | Static narrated site (reuse public-page renderer). | Missing | Med | L | later | Redact living persons at build time (static bypasses runtime engine) (NN#3). |
| Two-way desktop↔online sync | Bidirectional sync with change journals. Audit log could seed a change feed. | Missing | Med | XL | later | No Ancestry TreeShare / paywalled sync (NN#6). |
| Curator roles, trusted-list ACLs, field locking, projects/workspaces, forum, honor code, free-space wiki, portal homepage | Community-platform features. | Missing | Low | SXL | later | New roles/ACLs/locks integrate with the **single** enforcement point, not parallel checks. |
| Real-time co-editing | Out of scope; only optimistic concurrency planned. | Planned | Med | XL | later | Concurrent paths must route through privacy engine. |
---
### 2.10 Privacy & access control
The architecture is correct (single engine, tenant mixin, audit, soft-delete + purge are **have**), but enforcement coverage and configurability have real holes — two of which are security-priority.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **Uniform living-person redaction across child resources** | `_redact` runs on person reads but **not** on event/media/name/relationship/citation/source endpoints — non-members fetch a possibly-living person's events/photos/names directly. | Partial | **Critical** | M | 12 | **Security-priority. Core NN#3/NN#2 defect.** Apply `person_visibility` on every person-derived fact. |
| **Email-verification enforcement gate** | `email_verified_at` is written at `auth_service.py:154` but read on no path; `register` returns an authenticated session cookie + token (201) pre-verification. | Partial | **High** | S | 12 | **Security-priority near-quick-win** — add the read-side check (NN#7 trust path). The check is small; the registration-mode switch below is the larger piece. |
| Self-registration mode gating (approve / open / closed) | No env switch to choose open vs admin-approval vs closed registration. | Partial | High | M | 2/5 | Twelve-factor registration control (NN#7); pairs with the verification gate above. |
| **Fix `site_members` visibility tier** | Defined + selectable in UI but `can_view_tree` only handles public/unlisted — fails closed unintuitively. | Partial | Critical | S | 1 | **Quick win.** Least-surprise; honor the tier the UI offers. |
| Make `LIVING_RECENCY_YEARS` configurable | Hardcoded 100 at `privacy.py:23`. | Partial | High | S | 2 | **Quick win.** Twelve-factor (NN#7). |
| Privacy-stripped export (redact living) | GEDCOM + account export emit full tree; no "strip living" mode. | Missing | High | M | 2 | Reuse `person_visibility`/`_redact` (NN#3). Owner self-export is safe today; shareable variant is the gap. |
| Per-fact / per-field privacy + record flags | tentative/rejected/preferred/private flags on facts. | Missing | Med | L | later | If added, route through the single engine (NN#2). |
| Granular rules by record type & viewer relationship | webtrees-style "hide marriages from non-descendants". | Missing | Med | L | later | Single enforcement point. |
| OIDC / external IdP login | `AuthProvider` interface ready; only Local implemented. Authentik is the intended real auth. | Planned | High | L | 5 | Additive by design. |
| Two-factor auth (TOTP) | Bearer/cookie session auth is solid; no MFA. | Partial | High | L | 5 | — |
| DB-level audit immutability | Audit is insert-only by convention; no trigger/constraint. Verified as "adequate for self-host," so importance downgraded to match. | Have(soft) | Med | S | 9 | Adequate for self-host; upgrade to trigger only if true immutability is required. |
| Block/hide users, family-group private space, DNA opt-in controls | Depend on messaging/DNA. | Missing | LowMed | MXL | 6/parked | DNA parked (NN#6). |
---
### 2.11 Import/export & standards
GEDCOM 5.5.1 import/export and full data-portability export are **have**, but fidelity gaps directly undercut the provenance thesis — and one is outright data loss.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **Citation links dropped on GEDCOM export** | Export never selects the Citation table — fact→source links, page, detail, confidence all dropped on export (they import fine). Re-importing your own export **destroys** the sources-first graph. | Partial | **Critical** | M | 2 | **Silent data loss on the product's signature data + destructive round-trip** (NN#5); breaks PRD US-013. |
| GEDCOM 7.0 import/export | Version hardcoded `5.5.1`; no v7 semantics, SCHMA, SUBM, or UID handling. | Partial | High | L | 2 | Stated differentiator (FamilySearch interop). |
| Custom/underscore tag preservation | `_MARNM` becomes `TYPE married`, other custom tags dropped — violates ≥99% round-trip goal. | Missing | High | L | 2 | Tension with provenance thesis (faithful record). |
| PLAC FORM hierarchy + MAP coordinate round-trip | Import reads only PLAC text; export emits flat PLAC. lat/long + hierarchy lost on round-trip. | Missing | High | M | 23 | Round-trip fidelity for the land/maps pillar. |
| Encoding detection (ANSEL/UTF-16) | UTF-8 round-trips; non-UTF-8 files silently mangled via `errors='replace'`; CHAR tag ignored. | Partial | High | S | 2 | **Near quick win.** Detect/honor CHAR; reject or transcode rather than corrupt. |
| HEAD completeness | HEAD at `gedcom.py:740` emits only `SOUR/GEDC/VERS/CHAR` — missing required `2 FORM LINEAGE-LINKED` (under GEDC) and `1 SUBM`. | Partial | Med | S | 2 | **Quick win.** Pure conformance. |
| GEDCOM media (OBJE) round-trip | OBJE in skip-tags; media ignored on import, never emitted on export. | Partial | Med | M | 2 | Any media bundle keeps privacy gating. |
| GEDZIP (.gdz) bundle | Bundled-media packaging. | Missing | Med | M | 2 | Natural once v7 + OBJE land. |
| Selective / filtered export | Clippings-cart / branch subset. | Missing | Med | M | later | Maintain single-enforcement-point on export (NN#2). |
| Import conformance validation | Preview is a mapping report, not structural/cardinality validation; bad lines silently skipped. | Partial | Med | M | 2 | — |
| GEDCOM-X, Gramps XML, multi-format import, FHISO/ELF, PRF upload, KML export | Interop extras. | Missing | Low | L | later | PRF needs FamilySearch API (permitted, NN#6). |
---
### 2.12 Mobile & offline
Responsive web is **partial**; PWA and offline-first are absent. Native apps are an explicit deferral.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| PWA (manifest + icons + viewport + service worker) | No manifest, no SW, no `next-pwa`; responsive coverage exists but unaudited on heavy views (tree canvas fixed 74vh). | Partial | High | M | 2 | If SW caches API responses, never retain non-owner PII; cache only what the session is authorized to see (NN#3). |
| Responsive parity audit | 17 breakpoint usages; small-screen parity on tree/person views unverified. | Partial | High | M | 12 | Feature parity is an ARCH requirement. |
| Offline-first editing + reconnect sync | No SW, no local store, no mutation queue. Valuable for archive/courthouse field research. | Missing | High | XL | later | Replayed edits go through service layer + audit (NN#1); cached data respects living-person rule (NN#3). |
| Native mobile apps | Explicitly deferred (responsive web only). | Missing | Med | XL | later | If built, reads through one backend privacy engine (NN#2/#3/#4). |
| Companion app w/ cross-device sync | Largely redundant with server-backed web. | Missing | Low | XL | later | Sync boundary enforces privacy (NN#2); full CRUD parity (NN#8). |
| Relatives Around Me | Nearby-relatives discovery. | Missing | Low | L | later | Explicit opt-in; anonymous until mutual consent (NN#4). |
---
### 2.13 API & extensibility
Internal REST + OpenAPI + generated TS client are **have**. The externalized developer story and the connector/plugin spine are not built.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| Public read-only API + scoped tokens (OAuth) | Bearer token is opaque session only; `TokenPurpose` lacks scopes; designed `public.py` never built. | Partial | High | L | 56 | Any scoped-token path routes through `person_visibility` + living-person redaction (NN#2/#3). |
| SourceConnector framework | Only AuthProvider/ObjectStore/Mailer base classes exist; no connector base/loader/registry. Gates AI, hints, property connectors. | Planned | Med | L | 4 | Read-only, rate-limited; findings via ChangeProposal (NN#1); legal sources only (NN#6). |
| Webhooks / change feeds | `AuditEntry` is the natural substrate (shares the notification dispatch layer, §2.9); no feed/webhook layer. | Missing | Med | L | 6 | Emit privacy-filtered, tenant-scoped projections — never raw before/after JSON (NN#2/#3). |
| CLI / scripting surface | No `[project.scripts]`, no Typer/Click; worker is a purge loop only. Self-hosters want bulk admin. | Missing | Med | M | 9 | Funnel reads through privacy.py, writes through audit; admin-scoped, no assistant-write path. |
| Plugin/addon architecture | Connector framework only; no general UI/report/theme plugin system planned. | Planned | Med | L | later | Sandbox via service layer; no privacy/audit bypass, no writes outside ChangeProposal. |
| In-app query tooling (SuperTool) | Power-user expression engine. | Missing | Low | L | later | Execute through privacy engine — no row enumeration bypass (NN#2). |
| Certified partner program | Organizational, not software. | Missing | Low | XL | — | Out of scope until a hosted offering exists. |
---
### 2.14 Performance & scale
Postgres + S3, multi-tenant isolation are **have**. Queue, observability, backups, pagination, and scale validation are the gaps that gate Phases 4/7 — several are current functional limitations, not late-phase validation tasks.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| Real job queue (Postgres/Redis-backed) | Worker is a fixed-interval purge loop; GEDCOM import and account export run **inline in the request**. | Partial | High | L | 4 (pre-req) | Blocks NN#1 (assistant in worker) and NN#4 (hint matching in worker). Queue backend is an open question (PRD §11). |
| **Pagination on list endpoints + server-side tree loading** | List endpoints (`persons.py:37`, events, relationships) take no `limit/offset/skip`; the tree view loads the whole graph client-side. A *current* limitation against the 50k-person target. | Planned | High | M | 12 | **Split out from scale validation** — this is a correctness/functional gap now, not a Phase 9 task. |
| Scale validation (50k+ trees, P95<2s, load test) | No benchmark or load test exists. | Planned | High | L | 9 | Inline heavy ops risk partial writes — moving to the queue is what makes "failures never corrupt state" true. |
| **Operator backup: one-command `pg_dump` + MinIO sync** | Only a documented procedure + per-account ZIP exist; no scripted DB+object dump. For a self-host product this is day-one data-loss exposure. | Partial | Critical | M | 12 | **Pulled forward** — Critical importance contradicted the old Phase-9 slot. Restore must re-apply privacy state faithfully (NN#3); safety net for NN#8. |
| Scheduled / cloud automated backup + restore tooling | Cron-driven, off-host, verified-restore workflow. | Partial | High | L | 9 | Builds on the one-command slice above. |
| ARM64 build matrix | CI builds `linux/amd64` only; many self-hosters run ARM SBCs. | Partial | High | S | 1 | **Quick win.** Add arm64 + QEMU to buildx (NN#7 container-native). |
| Structured JSON logs + Prometheus metrics | Plain-text stdlib logging; no `/metrics`. | Partial | Med | M | 9 | Logs/metrics reference UUIDs, never names/PII (NN#3/#4). |
| pgvector enablement | Image has pgvector; app never creates the extension or adds embedding columns (docs claim otherwise). | Partial | Med | M | 7 | See §2.3 — embedding provider open question; candidates via privacy engine. |
| Database check-and-repair | No orphan/dangling-edge/cycle scanner (recent "harden tree render" commit shows bad graphs occur). | Missing | Med | M | 9 | Tenant-scoped + audited; auto-fix via ChangeProposal (NN#1). |
| Pluggable DB backend, billions-scale shared tree, weekly record releases | Different product models. | Missing | Low | XL | — | **Out of scope** — Postgres-only is consistent with the invariants; global shared tree conflicts with NN#2/#3/#4. |
---
### 2.15 Property / land chain-of-title — *headline differentiator*
The entire "land" half is **planned/missing** but fully specified. This is where Provenance has no real competitor.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| Property/parcel first-class entity | No model/endpoint/service/migration. Foundation for the whole category. | Planned | High | L | 3 | Full CRUD in API+UI (NN#8); reads added carefully to the **single** privacy engine (NN#2). |
| Typed OwnershipEvents | grant/patent, purchase, sale, inheritance, gift, tax sale, foreclosure, eminent domain — with grantor/grantee Persons + Citation. | Planned | High | L | 3 | Each event carries a Citation (NN#5); grantor/grantee living-person links redacted (NN#3). |
| Chain-of-title timeline + gap flagging | Ordered OwnershipEvents first-grant→present, breaks flagged. | Planned | High | M | 3 | The genuinely differentiating analytical piece (PRD US-032). |
| Bidirectional owner↔person, parcel↔place | "Every property a person held" / "every parcel at a place." | Planned | High | M | 3 | Reverse traversals filtered through privacy engine (NN#2). |
| Citations on OwnershipEvents | Add `ownership_event_id` to Citation (5th target). | Partial | Critical | S | 3 | **Quick win once Property lands** — single FK + CHECK edit (NN#5). |
| Legal description verbatim storage | metes-and-bounds / PLSS township-range-section as-written. | Planned | Med | L | 3 | Part of the Property model; preserves the record faithfully. |
| Parcel/plat boundary geometry | Optional geometry; plain coords first. | Planned | Med | L | 3+ | PostGIS is an open question (ARCH §14) — surface dependency. |
| PLSS / metes-and-bounds parsing → geometry | Automated survey parsing. | Planned | Med | XL | later | Hard; gated on PostGIS. |
| BLM/GLO federal land-patent connector | Marquee US land source. | Planned | High | L | 8 | Permitted source (NN#6); patents surface as ChangeProposals (NN#1); read-only + rate-limited. |
| USGS map + public county-deed connectors | Per-jurisdiction grantor/grantee indexes. | Planned | Med | L | 8 | Each connector verifies a legally open source (NN#6). |
| Co-ownership roles / tenure types | joint tenants, TIC, life estate, heirs. | Planned | Low | M | later | Multiple parties likely free with OwnershipEvent; role typing is a refinement. |
| Tax/assessment rolls, UK Tithe, Lloyd George Domesday | Valuation + non-US collections. | Missing | Low | ML | — | US-focused v1; international formats out of scope (model is country-agnostic). |
---
### 2.16 AI assistant — *defining differentiator*
Entirely **planned** — and note the docs-vs-code gap: ARCHITECTURE §5 lists `ChangeProposal` as part of the "landed" core model, but no model/migration/schema exists. The audit substrate (`actor_type=assistant`, before/after JSONB) is the right foundation; the ChangeProposal model and ModelProvider abstraction are the two critical-path pieces.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **ChangeProposal (propose-then-confirm)** | The defining invariant. No `proposal.py`, no migration, no review UI yet — despite docs implying it landed. | Planned | **Critical** | L | 4 | **IS NN#1.** Enforce structurally: assistant tools return proposals; only user action applies one; application flows through the normal service layer (privacy + audit). ChangeProposal itself needs full CRUD (NN#8). Correct the docs to match reality. |
| Pluggable LLM + embedding provider | `ModelProvider` over Anthropic/OpenAI/xAI/Ollama; env placeholders exist, no interface code. | Planned | Critical | M | 4 | **Twelve-factor, no hard-coded keys/endpoints** (NN#7); the Ollama/self-hosted path is what makes the privacy-first promise real. |
| AI research-assistant chatbot (RAG over tree) | Marquee feature; needs ModelProvider + connector + retrieval through privacy engine. | Planned | High | XL | 4 | NN#1 propose-only, NN#2 privacy retrieval, NN#3 redaction. |
| Conversational / connector record search | Search legal sources via the assistant. | Planned | High | L | 4 | Legal sources (NN#6); findings = Source + Citation (NN#5). |
| Fact extraction from documents | Extracted facts map cleanly to ChangeProposal review. | Missing | Med | M | 4 | Canonical NN#1 use case; each fact carries a Citation (NN#5). |
| OCR/HTR transcription + document translation | Worker job via ModelProvider. | Missing | Med | L | 4+ | Output → Source/Citation (NN#5); via ModelProvider (NN#7); auto-extraction emits ChangeProposal (NN#1). |
| Next-step research guidance | Gap analysis → suggested next record. | Planned | Med | M | 4 | Reads via privacy engine; advisory unless it queues fetches. |
| AI biography / audio narration | Read-only generation grounded in tree data. | Missing | Low | ML | later | Must not leak living-person PII (NN#3); via ModelProvider (NN#7); stored biographies = full CRUD (NN#8). |
---
### 2.17 Localization & accessibility
A documented **day-one commitment** ("UI strings externalized from day one") that is currently **unmet** — every label is a hardcoded literal. Correct the PRD claim or close the gap.
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **UI string externalization** | No i18n lib, no message catalogs; all copy hardcoded in TSX. Gating prerequisite; cheapest to do now while the surface is small. | Missing | High | L | 12 | PRD §6 promises this "from day one" — **docs-vs-code gap; edit the doc now.** |
| Multi-language UI (4060+ langs) | Translation pipeline after externalization (frontend + backend-generated messages). | Missing | High | XL | later | Table stakes across all competitors. |
| Accessibility / WCAG 2.2 AA | Some ARIA/focus styling; no CI a11y audit, no skip-links, SVG tree viz not keyboard/screen-reader navigable. | Partial | High | L | 2/9 | Stated PRD §6 target; add axe/pa11y in CI; accessible alternate to the chart. |
| Unicode-correct non-Latin names | Stores fine (UTF-8); no NFC normalization on write, no locale-aware collation, no romanized search. | Partial | High | M | 2 | Apply `unicodedata.normalize('NFC')` on input; add COLLATE; supports faithful-record goal. |
| Structured/compound surname components | Surname is a single field; no support for Spanish/Portuguese paternal+maternal, Arabic nasab, particles/prefixes. | Missing | Med | M | 2 | New Name sub-fields ship with full CRUD (NN#8); preserves the name as recorded. |
| Non-Gregorian calendar dates | `calendar` column is a placeholder; GEDCOM calendar escapes never parsed/populated. | Partial | Med | L | 2 | Preserve original calendar as recorded (sources-first). |
| Language tags / romanized variants per name | No language_tag/script/romanized fields; GEDCOM ROMN/LANG unhandled. | Missing | Med | M | 2 | New Name sub-fields ship with full CRUD (NN#8). |
| RTL support | `lang="en"` hardcoded, no `dir`, physical CSS properties throughout. | Missing | Med | M | later | Convert to logical CSS properties; cheaper once i18n exists. |
| Selectable themes | Light/dark/system works; brand palette intentionally single. | Partial | Med | M | later | Confirm whether additional themes are a deliberate non-goal (brand guide constrains palette). |
| Multi-language report/diagram output | Depends on i18n + reports, neither shipped. | Missing | Low | L | later | — |
---
## 3. Quick wins (high importance / low effort)
Ordered by leverage. All are S-effort or a thin slice of a larger item, and most close a stated invariant gap.
1. **Fix `site_members` visibility tier** (Privacy, Critical/S) — defined and selectable in the UI but never handled in `can_view_tree`; fails closed unintuitively.
2. **Email-verification enforcement gate** (Privacy/Auth, High/S) — add the read-side `email_verified_at` check so a freshly registered, unverified user doesn't get a live authenticated session. Security-priority; the registration-mode env switch (open/approve/closed) is the larger follow-on, not part of this quick win.
3. **Citation confidence selector in the cite form** (Sources, High/S) — confidence is modeled and API-writable but unreachable in the UI; every UI citation is currently NULL. Honors NN#8 and the evidence-quality thesis.
4. **Source edit UI + expose all 8 fields** (Sources, High/S) — update API exists but there is no edit form and create exposes ~3 fields; a create-but-not-edit entity violates NN#8.
5. **Make `LIVING_RECENCY_YEARS` env-configurable** (Privacy, High/S) — hardcoded 100 at `privacy.py:23`; twelve-factor (NN#7).
6. **Add `ownership_event_id` to Citation** (Property/Sources, Critical/S) — single FK + CHECK-constraint edit the moment Property lands; the spine is already built (NN#5).
7. **GEDCOM encoding detection** (Standards, High/S) — detect/honor the CHAR tag; reject or transcode ANSEL/UTF-16 rather than silently mangling with `errors='replace'`.
8. **GEDCOM HEAD completeness** (Standards, Med/S) — emit the required `2 FORM LINEAGE-LINKED` (under GEDC) and `1 SUBM` at `gedcom.py:740`. Pure conformance.
9. **ARM64 CI build matrix** (Perf/Scale, High/S) — add `linux/arm64` + QEMU to buildx for both images; many self-hosters run ARM SBCs.
10. **`GET /{tree}/citations/{id}` endpoint** (Sources, Med/S) — API symmetry (NN#8).
11. **Transcription/abstract fields on Source** (Sources, Med/S) — add `transcription_text` + `abstract_text`, distinct from `citation_text`; core to evidence analysis.
12. **Sort the merged person timeline** (Research workflow, Med/S) — `shownEvents.sort()` on `date_start`; currently appended unsorted.
13. **Doc corrections (docs-vs-code)** (Meta, trivial/S) — edit CLAUDE.md / ARCHITECTURE so the pgvector "used" claim, the i18n "from day one" claim, and the ChangeProposal "landed" claim match reality. The repo convention requires docs to travel with code.
> **Ships-with, not standalone:** *Revocable / adjustable access (membership PATCH/DELETE + role change)* is security-critical and S-effort, but it is the minimal slice of the membership work (§2.9) and ships **with** those endpoints — it is not independently shippable on its own.
>
> **Higher priority than any quick win, but M-effort (not quick):** the **media privacy leak** (§2.4), the **child-resource redaction gap** (§2.10), and pulling the **one-command operator backup** (§2.14) forward. Treat these as **security-/data-loss-priority Phase 12 fixes** regardless of the quick-win list.
---
## 4. Strategic differentiators
Where to invest to make Provenance distinct rather than a webtrees clone. Each leans on a non-negotiable as a *feature*, not a constraint.
**1. Property chain-of-title (the "land" half).** No surveyed competitor models ownership as a typed, cited event chain tying parties across time, with gap-flagging and bidirectional owner↔person / parcel↔place traversal, fed by **legal** public sources (BLM/GLO patents, USGS, public county deeds). This is the single clearest "no one else does this" capability. Sequence: Property + OwnershipEvent + Citation-target (Phase 3) → chain-of-title view → BLM/GLO connector (Phase 8). The Citation extension is a quick win; the entity is the prerequisite for everything else in the category.
**2. The ChangeProposal AI model.** "The assistant never writes autonomously" is a *trust* differentiator in a market where users fear AI corrupting their research. Build it structurally — assistant tools return proposals; only an explicit human action applies one; application flows through the normal service layer so it always hits the privacy engine and audit log. The same approval queue moderates untrusted human-contributor edits (Collaboration §2.9), so design them together. The audit substrate is already in place; ChangeProposal + ModelProvider are the critical path — and the docs should stop asserting ChangeProposal has landed until it has.
**3. Anonymous, mutual-consent cross-tree hints.** The privacy model already redacts living people for anonymous viewers, so a hint system that reveals *nothing identifying* until both sides opt in is achievable by construction — and is a categorically more trustworthy version of MyHeritage Smart Matches / Ancestry hints. Requires the matching engine (pgvector enablement + candidate generation, Phase 7), the notification/event-dispatch substrate (§2.9), and the messaging channel that opens only post-consent.
**4. True self-hosting + data ownership.** Full account export/import, soft-delete recovery, GEDCOM round-trip, env-driven everything, and (to-build) operator-grade scheduled backup + ARM support make Provenance the genealogy app you actually own. Two correctness items gate the promise: GEDCOM export must stop dropping citations (a Provenance→Provenance round-trip currently destroys the sources graph), and operator backup must move from "documented procedure" to a one-command dump. The Ollama/self-hosted ModelProvider path means even the AI assistant runs without tree data leaving the deployment — a promise no commercial competitor can make.
**5. Sources-first as a felt experience.** The two-tier model is built; the differentiator is making it *visible and low-friction*: a guided Evidence-Explained citation builder, transcription/abstract fields, source-driven data entry (transcribe a document into the tree), per-fact confidence surfaced in the UI, and — critically — citations that **survive GEDCOM export**. These turn "every fact links to where it came from" from an architecture note into the product's personality.
+157
View File
@@ -0,0 +1,157 @@
# Design note: tree visibility & the public viewing surface
Status: **proposed** (design only — no code yet). Owner: Justin. Created 2026-06-09.
This is a privacy-critical change (it creates the first anonymous read surface in
Provenance). Per CLAUDE.md, design before code. Implementation should land in
small, individually-reviewable PRs, with tests on the privacy engine and the
public read path before any anonymous endpoint is exposed.
## 1. The model
Visibility flattens **two axes***who may read* and *how discoverable* — into one
ordered enum for the UI:
| Level | Anonymous (no login) | Any logged-in user | Tree members | In-app directory | Search-indexed |
|---|---|---|---|---|---|
| `public` — anyone on the web | ✅ view¹ | ✅ view¹ | ✅ full | ✅ listed to everyone | ✅ sitemap + indexable |
| `site_members` — Public, Site Members | ❌ | ✅ view¹ | ✅ full | ✅ listed to logged-in users | ❌ (`noindex`) |
| `unlisted` — anyone with the link | ✅ via direct link¹ | ✅ via link¹ | ✅ full | ❌ never listed | ❌ (`noindex`) |
| `private` | ❌ | ❌ | ✅ full | ❌ | ❌ |
¹ **Every non-member view passes through the privacy engine.** Living people are
redacted, and per-person `private` hides / `public` reveals, exactly as
`person_visibility()` already does (`backend/app/services/privacy.py:100-110`).
This is the single enforcement point — no public code path may issue a raw query.
Decisions captured (2026-06-09):
- **Unlisted** = anyone with the link, no account required. The link must be
**unguessable** (the tree UUID is already non-enumerable; do not add a public
integer id). Unlisted trees are excluded from the directory and sitemap and
served `noindex`.
- **Public** discovery for v1 includes **an in-app public browse/search**, not
just search-engine indexing.
- **Public Site Members** = *any* registered account on this instance (not an
invite list — that is already tree membership / `private`).
## 2. Data model
`TreeVisibility` enum (`backend/app/models/enums.py`) gains a value:
```
public # anyone on the web
site_members # any authenticated user of this instance <-- NEW
unlisted # anyone with the link
private # members only (default)
```
- Alembic migration to `ALTER TYPE tree_visibility ADD VALUE 'site_members'`
(Postgres enum add-value cannot run inside a transaction with other DDL — use
`op.execute` with autocommit, separate migration).
- Default stays `private`. Existing rows unchanged.
- `TreeRead`/`TreeUpdate`/`TreeCreate` schemas already carry the enum; they pick
up the new value automatically. The OpenAPI client regen (`gen:api`) exposes it
to the frontend.
## 3. Privacy engine
`can_view_tree()` today treats `public` and `unlisted` identically and ignores
whether the viewer is anonymous vs authenticated (`privacy.py:44-49`). Replace the
final line with explicit branching on viewer auth state:
```
if membership: return True # members always
match tree.visibility:
public, unlisted: return True # anonymous OK (unlisted gated only by knowing the link)
site_members: return user_id is not None # any logged-in account
private: return False
```
`person_visibility()` is unchanged — it already redacts living/private people for
non-members. Add focused unit tests: anonymous + each visibility; living person
redacted on public/unlisted; `site_members` denies anonymous but allows a
logged-in non-member; `private` denies both.
## 4. The anonymous read path (the careful part)
**Recommendation: a dedicated read-only public API namespace**, not optional-auth
on the existing endpoints. Rationale: it is far easier to audit a small,
purpose-built surface that *always* funnels through `person_visibility` than to
weaken the membership checks on the authenticated endpoints and hope every branch
is covered.
- New router `app/api/v1/public.py`, mounted at `/api/v1/public`, with an
**optional-auth** dependency `CurrentUserOrNone` (returns `User | None`; never
401s). Contrast with `CurrentUser` (`deps.py:30-36`) which hard-401s.
- Endpoints (read-only; no create/update/delete):
- `GET /public/trees` — directory: lists `public` to everyone; additionally
lists `site_members` when the caller is authenticated. Paginated, search via
existing `pg_trgm`. Never lists `unlisted`/`private`.
- `GET /public/trees/{id}` — tree metadata if `can_view_tree(user_or_none)`.
- `GET /public/trees/{id}/persons`, `/persons/{pid}`, `/relationships`,
`/events`, `/media`, … — each filtered through `person_visibility`, returning
redacted projections (a `PublicPersonRead` that omits PII for redacted people:
no exact dates, no living-person names beyond "Living", etc.).
- **A redacted response schema**, distinct from the member `PersonRead`, so the
serializer physically cannot emit fields a non-member shouldn't see. Redaction
happens in the service, not the route.
- **Rate limiting** on the public namespace (per-IP) to blunt scraping/enumeration.
- **Audit**: count public reads; do not log PII.
## 5. Frontend public pages
- New **server-rendered** routes outside the authed app shell, e.g.
`/p/[treeId]` (tree), `/p/[treeId]/[personId]` (person), `/explore` (directory).
Server components fetch the `/api/v1/public/*` endpoints; no login redirect.
- `robots`: allow + sitemap for `public`; `noindex, nofollow` meta for `unlisted`
and `site_members`. Sitemap lists only `public` trees/persons.
- The directory `/explore` is anonymous for `public`; shows `site_members` trees
only to logged-in users.
- Reuse the tree/person view components where possible, fed by the redacted
schema.
## 6. UI control
Update the visibility dropdown (`frontend/app/trees/page.tsx`, shipped in PR #41)
from 3 to 4 options with helper text:
```
Private — only you and people you invite
Public Members — any signed-in user on this site
Unlisted — anyone with the link (not listed or indexed)
Public — anyone on the web; listed and search-indexable
```
A short confirmation when switching *to* `public` ("This makes <tree> visible to
anyone on the web. Living people stay hidden.") is worthwhile given the stakes.
## 7. Guardrails / invariants
- One enforcement point: every public response is built from `person_visibility`
output. No raw repository reads in the public router.
- Living-person protection holds regardless of tree visibility.
- Unlisted relies on UUID unguessability; never expose a sequential public id.
- `noindex` everything except `public`; sitemap is `public`-only.
- Tests gate the merge: privacy-engine matrix + an integration test that hits the
public endpoints anonymously and asserts no living-person PII leaks.
## 8. Suggested phasing (small PRs)
1. Enum value + migration + regen client (+ dropdown → 4 options). No behavior
change yet for non-members.
2. Privacy-engine branching + unit tests.
3. Public read API namespace (optional-auth, redacted schema, rate limit) + tests.
4. Public frontend pages (`/p/...`) + robots/sitemap.
5. In-app `/explore` directory + search.
Steps 23 are the privacy-critical core and should be reviewed hardest.
## 9. Open questions
- Caching: public pages are cacheable for SEO, but cache keys must not blur the
redacted-vs-member rendering. Likely: cache only the anonymous projection at the
edge; never cache member responses.
- Do `site_members` trees appear in the sitemap for logged-in crawling? (Default:
no — `noindex`.)
- Per-tree opt-out of the directory even when `public`? (Probably unnecessary;
`unlisted` already covers "reachable but not listed.")
+1
View File
@@ -3,4 +3,5 @@
/out /out
/build /build
next-env.d.ts next-env.d.ts
*.tsbuildinfo
.env*.local .env*.local
+3
View File
@@ -3,6 +3,9 @@
FROM node:22-bookworm-slim AS deps FROM node:22-bookworm-slim AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
# patches/ must be present before `npm ci` so the postinstall (patch-package)
# can apply our vendored family-chart layout fix.
COPY patches ./patches
RUN npm ci RUN npm ci
FROM node:22-bookworm-slim AS build FROM node:22-bookworm-slim AS build
+10
View File
@@ -0,0 +1,10 @@
import { PublicHeader } from "@/components/public-header";
export default function ExploreLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<PublicHeader />
<main className="mx-auto max-w-5xl px-4 py-8">{children}</main>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Tree = components["schemas"]["PublicTreeRead"];
// Public directory of trees. The backend returns `public` to everyone and adds
// `site_members` trees when the request carries a valid session — so signed-in
// users see more here without any client-side branching.
export default function ExplorePage() {
const [trees, setTrees] = useState<Tree[]>([]);
const [search, setSearch] = useState("");
const [ready, setReady] = useState(false);
useEffect(() => {
const q = search.trim();
const t = setTimeout(async () => {
const { data } = await api.GET("/api/v1/public/trees", {
params: { query: q ? { q } : {} },
});
setTrees(data ?? []);
setReady(true);
}, 200);
return () => clearTimeout(t);
}, [search]);
return (
<div className="space-y-6">
<div>
<h1 className="font-serif text-3xl font-semibold">Explore public trees</h1>
<p className="mt-1 text-[var(--muted)]">
Browse family trees shared on this site. Living people are always hidden.
</p>
</div>
<Input
className="w-72"
placeholder="Search trees by name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{!ready ? (
<p className="text-[var(--muted)]">Loading</p>
) : trees.length === 0 ? (
<p className="text-[var(--muted)]">No public trees{search.trim() ? " match that search" : " yet"}.</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{trees.map((t) => (
<li key={t.id}>
<Link href={`/p/${t.id}`}>
<Card className="h-full transition-colors hover:border-bronze/50">
<CardContent className="p-4">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{t.name}</span>
<span className="shrink-0 text-xs uppercase tracking-wide text-[var(--muted)]">
{t.visibility === "site_members" ? "Members" : "Public"}
</span>
</div>
{t.description && (
<p className="mt-1 line-clamp-2 text-sm text-[var(--muted)]">{t.description}</p>
)}
</CardContent>
</Card>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+66 -9
View File
@@ -11,23 +11,28 @@
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif; --font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
} }
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant. */ /* Adaptive tokens ink/paper flip for light/dark; bronze + paper are constant.
Theme is class-based (.dark on <html>) so it can be toggled manually; an inline
script in the root layout sets it pre-paint from the saved choice or the OS. */
:root { :root {
--background: #f7f3ec; --background: #f7f3ec;
--foreground: #1a1a17; --foreground: #1a1a17;
--muted: #6b6862; --muted: #6b6862;
--surface: #fffdf9; --surface: #fffdf9;
--border: #e6ddcc; --border: #e6ddcc;
/* Connector "lines between people" (pedigree + tree chart). Derived from Ink
(the brand mark color): a dark line on light, light on dark. */
--line: color-mix(in srgb, var(--foreground) 55%, transparent);
color-scheme: light;
} }
@media (prefers-color-scheme: dark) { .dark {
:root { --background: #161410;
--background: #161410; --foreground: #f2eee6;
--foreground: #f2eee6; --muted: #9a968e;
--muted: #9a968e; --surface: #211d17;
--surface: #211d17; --border: #353029;
--border: #353029; color-scheme: dark;
}
} }
body { body {
@@ -54,3 +59,55 @@ h3,
::selection { ::selection {
background: color-mix(in srgb, var(--color-bronze) 22%, transparent); background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
} }
/* Pedigree bracket connectors (ancestors grow rightward). Each leaf draws its
own half of the vertical spine + a horizontal stub, so lines stay correct
regardless of box heights: focus 2 parents, each parent 2 grandparents. */
.ped-person {
display: flex;
align-items: center;
}
.ped-self {
flex-shrink: 0;
}
.ped-branch {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-left: 2.5rem;
}
.ped-branch::before {
content: "";
position: absolute;
left: -2.5rem;
top: 50%;
width: 2.5rem;
border-top: 1px solid var(--line);
}
.ped-leaf {
position: relative;
padding-left: 1.5rem;
}
.ped-leaf::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 1.5rem;
border-top: 1px solid var(--line);
}
.ped-leaf::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--line);
}
.ped-leaf:first-child::after {
top: 50%;
}
.ped-leaf:last-child::after {
bottom: 50%;
}
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from "@/components/app-shell";
export default function ImportLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+15
View File
@@ -0,0 +1,15 @@
import { GedcomImport } from "@/components/gedcom-import";
export default function ImportPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Import</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Bring in a GEDCOM file start a brand-new tree, or add to one you already have.
</p>
</div>
<GedcomImport />
</div>
);
}
+8 -1
View File
@@ -18,9 +18,16 @@ export const metadata: Metadata = {
icons: { icon: "/favicon.svg" }, icons: { icon: "/favicon.svg" },
}; };
// Sets the theme class before first paint to avoid a flash; reads the saved
// choice ("light"/"dark"/"system") or falls back to the OS preference.
const themeScript = `(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||((!t||t==="system")&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`;
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className={`${serif.variable} ${sans.variable}`}> <html lang="en" className={`${serif.variable} ${sans.variable}`} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-screen antialiased">{children}</body> <body className="min-h-screen antialiased">{children}</body>
</html> </html>
); );
+157
View File
@@ -0,0 +1,157 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { PublicTreeChart } from "@/components/public-tree-chart";
type Person = components["schemas"]["PersonRead"];
type Event = components["schemas"]["EventRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Tree = components["schemas"]["PublicTreeRead"];
// Public, no-login view of a tree. Everything here is already redacted by the
// /api/v1/public surface (living people show as "Living person").
export default function PublicTreePage() {
const { treeId } = useParams<{ treeId: string }>();
const router = useRouter();
const [tree, setTree] = useState<Tree | null>(null);
const [people, setPeople] = useState<Person[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [focusId, setFocusId] = useState<string | null>(null);
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
const [search, setSearch] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
const t = await api.GET("/api/v1/public/trees/{tree_id}", {
params: { path: { tree_id: treeId } },
});
if (cancelled) return;
if (!t.data) {
setStatus("notfound");
return;
}
const [p, e, r] = await Promise.all([
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/public/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
}),
]);
if (cancelled) return;
const ppl = p.data ?? [];
const home = t.data.home_person_id;
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
setTree(t.data);
setPeople(ppl);
setEvents(e.data ?? []);
setRels(r.data ?? []);
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
setStatus("ready");
})();
return () => {
cancelled = true;
};
}, [treeId]);
const years = useMemo(() => {
const m = new Map<string, string>();
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
for (const p of people) {
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}${parts[1]}`.replace(/^$/, ""));
}
return m;
}, [people, events]);
const shown = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
);
return (q ? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) : sorted).slice(
0,
300,
);
}, [people, search]);
if (status === "loading") return <p className="text-[var(--muted)]">Loading</p>;
if (status === "notfound")
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Not available</h1>
<p className="text-[var(--muted)]">
This tree isnt public, or the link is wrong.{" "}
<Link href="/login" className="text-bronze hover:underline">
Sign in
</Link>{" "}
if its yours.
</p>
</div>
);
return (
<div className="space-y-6">
<div className="mx-auto w-full max-w-5xl">
<h1 className="font-serif text-3xl font-semibold">{tree?.name}</h1>
{tree?.description && <p className="mt-1 text-[var(--muted)]">{tree.description}</p>}
<p className="mt-1 text-sm text-[var(--muted)]">
{people.length} {people.length === 1 ? "person" : "people"} · living people are hidden
</p>
</div>
{/* Chart spans the full canvas (the layout removes max-width for /p/<id>). */}
{focusId && people.length > 0 && (
<PublicTreeChart
people={people}
rels={rels}
events={events}
focusId={focusId}
onFocus={setFocusId}
onOpen={(id) => router.push(`/p/${treeId}/persons/${id}`)}
/>
)}
<div className="mx-auto w-full max-w-5xl space-y-3">
<h2 className="font-serif text-base font-semibold">All people</h2>
<Input
className="w-72"
placeholder="Search people…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Card className="mx-auto w-full max-w-5xl overflow-hidden">
<CardContent className="p-0">
{shown.length === 0 ? (
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
) : (
shown.map((p, i) => (
<Link
key={p.id}
href={`/p/${treeId}/persons/${p.id}`}
className={`flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-bronze/[0.05] ${
i > 0 ? "border-t border-[var(--border)]" : ""
}`}
>
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
</Link>
))
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,163 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Card, CardContent } from "@/components/ui/card";
type Person = components["schemas"]["PersonRead"];
type Name = components["schemas"]["NameRead"];
type Event = components["schemas"]["EventRead"];
type Relationship = components["schemas"]["RelationshipRead"];
// Public, no-login person view. The /api/v1/public surface returns only what an
// anonymous viewer may see: deceased people in full, living people redacted.
export default function PublicPersonPage() {
const { treeId, personId } = useParams<{ treeId: string; personId: string }>();
const [person, setPerson] = useState<Person | null>(null);
const [names, setNames] = useState<Name[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
useEffect(() => {
let cancelled = false;
(async () => {
const pr = await api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
});
if (cancelled) return;
if (!pr.data) {
setStatus("notfound");
return;
}
const [nm, ev, ppl, rl] = await Promise.all([
api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/events", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
}),
]);
if (cancelled) return;
setPerson(pr.data);
setNames(nm.data ?? []);
setEvents(ev.data ?? []);
setPeople(ppl.data ?? []);
setRels(rl.data ?? []);
setStatus("ready");
})();
return () => {
cancelled = true;
};
}, [treeId, personId]);
const nameOf = useMemo(
() => (id: string) => people.find((p) => p.id === id)?.primary_name ?? "Unknown",
[people],
);
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
const partners = rels.filter(
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
);
const otherEnd = (r: Relationship) =>
r.person_from_id === personId ? r.person_to_id : r.person_from_id;
if (status === "loading") return <p className="text-[var(--muted)]">Loading</p>;
if (status === "notfound")
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Not available</h1>
<p className="text-[var(--muted)]">This person isnt publicly visible.</p>
<Link href={`/p/${treeId}`} className="text-sm text-bronze hover:underline">
Back to the tree
</Link>
</div>
);
const relGroup = (label: string, items: Relationship[]) =>
items.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
<ul className="mt-1 space-y-1">
{items.map((r) => (
<li key={r.id} className="text-sm">
<Link
href={`/p/${treeId}/persons/${otherEnd(r)}`}
className="hover:underline"
>
{nameOf(otherEnd(r))}
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
</Link>
</li>
))}
</ul>
</div>
);
return (
<div className="space-y-6">
<Link href={`/p/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
Back to the tree
</Link>
<h1 className="font-serif text-3xl font-semibold">{person?.primary_name ?? "Unnamed"}</h1>
<div className="grid gap-5 sm:grid-cols-2">
{events.length > 0 && (
<Card>
<CardContent className="space-y-2 p-5">
<h2 className="font-serif text-base font-semibold">Events</h2>
<ul className="space-y-1 text-sm">
{events.map((e) => (
<li key={e.id} className="flex justify-between gap-3">
<span className="capitalize">{e.event_type}</span>
<span className="text-[var(--muted)]">
{e.date_value ?? e.date_start ?? ""}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{names.length > 1 && (
<Card>
<CardContent className="space-y-2 p-5">
<h2 className="font-serif text-base font-semibold">Names</h2>
<ul className="space-y-1 text-sm">
{names.map((n) => (
<li key={n.id} className="flex justify-between gap-3">
<span>{[n.given, n.surname].filter(Boolean).join(" ") || "—"}</span>
<span className="text-[var(--muted)]">{n.name_type}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
{(parents.length > 0 || children.length > 0 || partners.length > 0) && (
<Card>
<CardContent className="grid gap-4 p-5 sm:grid-cols-3">
{relGroup("Parents", parents)}
{relGroup("Partners", partners)}
{relGroup("Children", children)}
</CardContent>
</Card>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { usePathname } from "next/navigation";
import { PublicHeader } from "@/components/public-header";
// Public viewing surface — no auth, no app sidebar. The tree page (/p/<id>)
// wants the whole canvas like the member tree view; the person detail page
// reads better in a centered column.
export default function PublicLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const fullWidth = /^\/p\/[^/]+$/.test(pathname);
return (
<div className="min-h-screen">
<PublicHeader />
<main className={fullWidth ? "w-full px-4 py-6 md:px-6" : "mx-auto max-w-5xl px-4 py-8"}>
{children}
</main>
</div>
);
}
+8
View File
@@ -31,6 +31,9 @@ export default function Home() {
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" /> <img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link> </Link>
<nav className="flex items-center gap-5 text-sm"> <nav className="flex items-center gap-5 text-sm">
<Link href="/explore" className="text-[var(--muted)] hover:text-[var(--foreground)]">
Explore
</Link>
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]"> <Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
Trees Trees
</Link> </Link>
@@ -66,6 +69,11 @@ export default function Home() {
Sign in Sign in
</Button> </Button>
</Link> </Link>
<Link href="/explore">
<Button size="lg" variant="ghost">
Explore public trees
</Button>
</Link>
</div> </div>
</div> </div>
+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from "next";
// Allow crawlers on the public surface; keep the authenticated app out of the
// index. (Per-tree noindex for `unlisted`/`site_members` pages needs server
// rendering — tracked as a follow-up; those trees aren't linked or listed, so
// they aren't discoverable by crawl in the meantime.)
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: ["/", "/p/"],
disallow: ["/trees", "/settings", "/import", "/login", "/register"],
},
};
}
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from "@/components/app-shell";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export default function SettingsPage() {
const router = useRouter();
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
// Data export / restore / delete.
const [exporting, setExporting] = useState(false);
const [restoreMsg, setRestoreMsg] = useState<string | null>(null);
const [restoring, setRestoring] = useState(false);
const restoreRef = useRef<HTMLInputElement>(null);
const [deleteConfirm, setDeleteConfirm] = useState("");
const [deleting, setDeleting] = useState(false);
useEffect(() => {
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
}, []);
async function exportData() {
setExporting(true);
const resp = await fetch("/api/v1/users/me/export", { credentials: "include" });
if (resp.ok) {
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "provenance-export.zip";
a.click();
URL.revokeObjectURL(url);
}
setExporting(false);
}
async function restoreData(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (restoreRef.current) restoreRef.current.value = "";
if (!file) return;
setRestoring(true);
setRestoreMsg(null);
const fd = new FormData();
fd.append("file", file);
const resp = await fetch("/api/v1/users/me/import", {
method: "POST",
body: fd,
credentials: "include",
});
setRestoring(false);
if (resp.ok) {
const c = await resp.json();
setRestoreMsg(`Restored ${c.trees} tree(s), ${c.persons} people into new trees.`);
} else {
setRestoreMsg("That doesn't look like a valid Provenance export.");
}
}
async function deleteAccount() {
if (deleteConfirm !== me?.email) return;
setDeleting(true);
const fd = new FormData();
fd.append("confirm_email", deleteConfirm);
const resp = await fetch("/api/v1/users/me", {
method: "DELETE",
body: fd,
credentials: "include",
});
setDeleting(false);
if (resp.ok) {
await api.POST("/api/v1/auth/logout");
router.push("/register");
}
}
async function changePassword(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (next.length < 8) {
setMsg({ kind: "err", text: "New password must be at least 8 characters." });
return;
}
if (next !== confirm) {
setMsg({ kind: "err", text: "New passwords don't match." });
return;
}
setBusy(true);
const { error } = await api.POST("/api/v1/auth/change-password", {
body: { current_password: current, new_password: next },
});
setBusy(false);
if (error) {
setMsg({ kind: "err", text: "Current password is incorrect." });
return;
}
setCurrent("");
setNext("");
setConfirm("");
setMsg({ kind: "ok", text: "Password changed." });
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<div>
<span className="text-[var(--muted)]">Name: </span>
{me?.display_name ?? "—"}
</div>
<div>
<span className="text-[var(--muted)]">Email: </span>
{me?.email ?? "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Change password</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={changePassword} className="flex max-w-sm flex-col gap-3">
<Input
type="password"
placeholder="Current password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
<Input
type="password"
placeholder="New password (min 8 chars)"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
<Input
type="password"
placeholder="Confirm new password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{msg && (
<p className={msg.kind === "ok" ? "text-sm text-bronze" : "text-sm text-red-600"}>
{msg.text}
</p>
)}
<Button type="submit" disabled={busy || !current || !next}>
{busy ? "Saving…" : "Change password"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Your data</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
Download a complete backup of every tree you own people, sources, media, and
all as a zip.
</p>
<Button variant="outline" onClick={exportData} disabled={exporting}>
{exporting ? "Preparing…" : "Export all my data"}
</Button>
</div>
<div className="space-y-2 border-t border-[var(--border)] pt-4">
<p className="text-sm text-[var(--muted)]">
Restore a backup. Its imported into <strong>new</strong> trees nothing existing
is touched or overwritten.
</p>
<input ref={restoreRef} type="file" accept=".zip" onChange={restoreData} className="hidden" />
<Button variant="outline" onClick={() => restoreRef.current?.click()} disabled={restoring}>
{restoring ? "Restoring…" : "Restore from backup"}
</Button>
{restoreMsg && <p className="text-sm text-bronze">{restoreMsg}</p>}
</div>
</CardContent>
</Card>
<Card className="border-red-300/60">
<CardHeader>
<CardTitle className="text-base text-red-700">Delete account</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
This deletes your account, the trees you own, and signs you out everywhere. Export
your data first if you might want it. Type <strong>{me?.email}</strong> to confirm.
</p>
<div className="flex max-w-sm flex-col gap-2">
<Input
placeholder="your email"
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
/>
<Button
variant="ghost"
className="bg-red-600 text-white hover:bg-red-700 hover:text-white"
onClick={deleteAccount}
disabled={deleting || deleteConfirm !== me?.email}
>
{deleting ? "Deleting…" : "Permanently delete my account"}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+407
View File
@@ -0,0 +1,407 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Deceased = components["schemas"]["DeceasedCandidate"];
type GenderProp = components["schemas"]["GenderProposal"];
type NameIssue = components["schemas"]["NameIssue"];
type Person = components["schemas"]["PersonRead"];
const ISSUE_LABEL: Record<string, string> = {
date_in_surname: "date in surname",
date_in_given: "date in given name",
no_surname: "no surname",
packed_given: "long given name",
};
export default function CleanupPage() {
const params = useParams<{ id: string }>();
const treeId = params.id;
// 1) Deceased by birth year
const [year, setYear] = useState(1930);
const [deceased, setDeceased] = useState<Deceased[] | null>(null);
const [decSel, setDecSel] = useState<Set<string>>(new Set());
const [decMsg, setDecMsg] = useState<string | null>(null);
// 2) Gender from source GEDCOM
const [gender, setGender] = useState<GenderProp[] | null>(null);
const [genSel, setGenSel] = useState<Set<string>>(new Set());
const [genMsg, setGenMsg] = useState<string | null>(null);
const genFile = useRef<HTMLInputElement>(null);
// People still missing a sex (manual mop-up)
const [unset, setUnset] = useState<Person[] | null>(null);
// 3) Name issues
const [issues, setIssues] = useState<NameIssue[] | null>(null);
const [edits, setEdits] = useState<Record<string, { given: string; surname: string; on: boolean }>>({});
const [nameMsg, setNameMsg] = useState<string | null>(null);
async function previewDeceased() {
setDecMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId }, query: { born_on_or_before: year } },
});
setDeceased(data ?? []);
setDecSel(new Set((data ?? []).map((d) => d.person_id)));
}
async function applyDeceased() {
const ids = [...decSel];
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId } },
body: { person_ids: ids },
});
setDecMsg(`Marked ${data?.updated ?? 0} people deceased.`);
setDeceased(null);
}
async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (genFile.current) genFile.current.value = "";
if (!file) return;
setGenMsg(null);
const fd = new FormData();
fd.append("file", file);
const resp = await fetch(`/api/v1/trees/${treeId}/cleanup/gender/preview`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) {
const data: GenderProp[] = await resp.json();
setGender(data);
setGenSel(new Set(data.map((g) => g.person_id)));
}
}
const loadUnset = useCallback(async () => {
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
setUnset(
(data ?? [])
.filter((p) => !p.gender)
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? "")),
);
}, [treeId]);
async function setSex(personId: string, gender: "male" | "female") {
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: { gender },
});
setUnset((prev) => (prev ? prev.filter((p) => p.id !== personId) : prev));
}
async function guessGender() {
setGenMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/guess", {
params: { path: { tree_id: treeId } },
});
setGender(data ?? []);
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
}
async function guessGenderFromSpouse() {
setGenMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/from-spouse", {
params: { path: { tree_id: treeId } },
});
setGender(data ?? []);
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
}
async function applyGender() {
const updates = (gender ?? [])
.filter((g) => genSel.has(g.person_id))
.map((g) => ({ person_id: g.person_id, gender: g.proposed_gender }));
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/gender", {
params: { path: { tree_id: treeId } },
body: { updates },
});
setGenMsg(`Set gender on ${data?.updated ?? 0} people.`);
setGender(null);
loadUnset();
}
const loadNames = useCallback(async () => {
setNameMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/names", {
params: { path: { tree_id: treeId } },
});
setIssues(data ?? []);
const init: Record<string, { given: string; surname: string; on: boolean }> = {};
for (const i of data ?? []) {
init[i.name_id] = { given: i.given ?? "", surname: i.surname ?? "", on: false };
}
setEdits(init);
}, [treeId]);
useEffect(() => {
loadNames();
loadUnset();
}, [loadNames, loadUnset]);
async function applyNames() {
const chosen = (issues ?? []).filter((i) => edits[i.name_id]?.on);
const body = {
edits: chosen.map((i) => ({
name_id: i.name_id,
given: edits[i.name_id].given,
surname: edits[i.name_id].surname,
})),
};
if (!body.edits.length) return;
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/names", {
params: { path: { tree_id: treeId } },
body,
});
setNameMsg(`Fixed ${data?.updated ?? 0} names.`);
loadNames();
}
const toggle = (set: Set<string>, id: string, setter: (s: Set<string>) => void) => {
const n = new Set(set);
if (n.has(id)) n.delete(id);
else n.add(id);
setter(n);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Cleanup</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Fix common import messes in bulk. Each tool previews its changes nothing is saved
until you apply.
</p>
</div>
{/* 1) Deceased by year */}
<Card>
<CardHeader>
<CardTitle className="text-base">Mark deceased by birth year</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-[var(--muted)]">Born on or before</span>
<Input
type="number"
className="w-28"
value={year}
onChange={(e) => setYear(Number(e.target.value))}
/>
</label>
<Button variant="outline" onClick={previewDeceased}>
Preview
</Button>
</div>
{decMsg && <p className="text-sm text-bronze">{decMsg}</p>}
{deceased && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{deceased.length} people born {year} (not already marked deceased).
</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{deceased.map((d) => (
<li key={d.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={decSel.has(d.person_id)}
onChange={() => toggle(decSel, d.person_id, setDecSel)}
/>
<span className="flex-1">{d.name}</span>
<span className="text-xs text-[var(--muted)]">b. {d.birth_year}</span>
</li>
))}
</ul>
{deceased.length > 0 && (
<Button onClick={applyDeceased}>Mark {decSel.size} deceased</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* 2) Gender from source */}
<Card>
<CardHeader>
<CardTitle className="text-base">Set sex from a source GEDCOM</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Upload your source <code>.ged</code> (it carries each persons sex). We match by
name and propose sex only for people who dont have it set.
</p>
<input
ref={genFile}
type="file"
accept=".ged,.gedcom,text/plain"
onChange={previewGender}
className="hidden"
/>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => genFile.current?.click()}>
Choose source GEDCOM
</Button>
<Button variant="outline" onClick={guessGender}>
Guess from first name
</Button>
<Button variant="outline" onClick={guessGenderFromSpouse}>
Infer from spouse
</Button>
</div>
<p className="text-xs text-[var(--muted)]">
Guess from first name uses a built-in name dictionary for people with no sex set.
Infer from spouse sets the opposite sex for an unset partner of someone whose sex is
known (e.g. a confirmed-male husband a female wife) review before applying, since
it assumes opposite-sex couples.
</p>
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
{gender && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">{gender.length} matches with a sex to set.</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{gender.map((g) => (
<li key={g.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={genSel.has(g.person_id)}
onChange={() => toggle(genSel, g.person_id, setGenSel)}
/>
<span className="flex-1">{g.name}</span>
<span
className="text-xs"
style={{
color:
g.proposed_gender === "male"
? "rgb(120,159,172)"
: "rgb(196,138,146)",
}}
>
{g.proposed_gender}
</span>
</li>
))}
</ul>
{gender.length > 0 && (
<Button onClick={applyGender}>Set sex on {genSel.size} people</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* People still missing a sex */}
<Card>
<CardHeader>
<CardTitle className="text-base">
People with no sex set{unset ? ` (${unset.length})` : ""}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{unset === null ? (
<p className="text-sm text-[var(--muted)]">Loading</p>
) : unset.length === 0 ? (
<p className="text-sm text-[var(--muted)]">Everyone has a sex set. 🎉</p>
) : (
<ul className="max-h-80 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{unset.map((p) => (
<li key={p.id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<a
href={`/trees/${treeId}/persons/${p.id}`}
className="flex-1 truncate hover:underline"
>
{p.primary_name ?? "Unnamed"}
</a>
<button
onClick={() => setSex(p.id, "male")}
className="rounded px-2 py-0.5 text-xs"
style={{ color: "rgb(120,159,172)", border: "1px solid rgb(120,159,172)" }}
>
Male
</button>
<button
onClick={() => setSex(p.id, "female")}
className="rounded px-2 py-0.5 text-xs"
style={{ color: "rgb(196,138,146)", border: "1px solid rgb(196,138,146)" }}
>
Female
</button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* 3) Name issues */}
<Card>
<CardHeader>
<CardTitle className="text-base">Names that look broken</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{nameMsg && <p className="text-sm text-bronze">{nameMsg}</p>}
{issues === null ? (
<p className="text-sm text-[var(--muted)]">Scanning</p>
) : issues.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No obvious name problems found.</p>
) : (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{issues.length} flagged. Edit given/surname, tick the ones to fix, then apply.
</p>
<ul className="space-y-2">
{issues.map((i) => {
const e = edits[i.name_id] ?? { given: "", surname: "", on: false };
return (
<li key={i.name_id} className="flex flex-wrap items-center gap-2 text-sm">
<input
type="checkbox"
checked={e.on}
onChange={() =>
setEdits((p) => ({ ...p, [i.name_id]: { ...e, on: !e.on } }))
}
/>
<Input
className="h-9 w-40"
placeholder="Given"
value={e.given}
onChange={(ev) =>
setEdits((p) => ({ ...p, [i.name_id]: { ...e, given: ev.target.value } }))
}
/>
<Input
className="h-9 w-40"
placeholder="Surname"
value={e.surname}
onChange={(ev) =>
setEdits((p) => ({
...p,
[i.name_id]: { ...e, surname: ev.target.value },
}))
}
/>
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
{ISSUE_LABEL[i.issue] ?? i.issue}
</span>
</li>
);
})}
</ul>
<Button onClick={applyNames}>Fix selected</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { GedcomImport } from "@/components/gedcom-import";
export default function TreeGedcomPage() {
const params = useParams<{ id: string }>();
const treeId = params.id;
async function exportGed() {
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" });
if (!resp.ok) return;
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "tree.ged";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Import &amp; export GEDCOM</h1>
<GedcomImport fixedTreeId={treeId} />
<Card>
<CardHeader>
<CardTitle className="text-base">Export this tree</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Download this tree as a GEDCOM file people, relationships, events, and sources.
</p>
<Button variant="outline" onClick={exportGed}>
Download .ged
</Button>
</CardContent>
</Card>
</div>
);
}
+29
View File
@@ -9,6 +9,9 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
type Media = components["schemas"]["MediaRead"]; type Media = components["schemas"]["MediaRead"];
type Person = components["schemas"]["PersonRead"];
const fieldCls = "h-8 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-xs";
function humanSize(bytes: number) { function humanSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -22,6 +25,7 @@ export default function MediaPage() {
const treeId = params.id; const treeId = params.id;
const [items, setItems] = useState<Media[]>([]); const [items, setItems] = useState<Media[]>([]);
const [people, setPeople] = useState<Person[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -34,10 +38,22 @@ export default function MediaPage() {
router.push("/login"); router.push("/login");
return; return;
} }
const ppl = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
setItems(data ?? []); setItems(data ?? []);
setPeople(ppl.data ?? []);
setReady(true); setReady(true);
}, [router, treeId]); }, [router, treeId]);
async function linkPerson(mediaId: string, personId: string) {
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
params: { path: { tree_id: treeId, media_id: mediaId } },
body: { person_id: personId || null },
});
load();
}
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
@@ -124,6 +140,19 @@ export default function MediaPage() {
× ×
</button> </button>
</div> </div>
<select
className={`${fieldCls} mt-2`}
value={m.person_id ?? ""}
onChange={(e) => linkPerson(m.id, e.target.value)}
title="Link to a person"
>
<option value=""> link to person </option>
{people.map((p) => (
<option key={p.id} value={p.id}>
{p.primary_name ?? "Unnamed"}
</option>
))}
</select>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
+466 -54
View File
@@ -1,36 +1,74 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
export default function TreeDetailPage() { function splitName(full: string): { given: string | null; surname: string | null } {
const t = full.trim().split(/\s+/).filter(Boolean);
if (t.length === 0) return { given: null, surname: null };
if (t.length === 1) return { given: t[0], surname: null };
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
}
type AddKind = "parent" | "child" | "partner";
export default function FamilyViewPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const searchParams = useSearchParams();
const treeId = params.id; const treeId = params.id;
// ?focus=… lets another view (or a person page) hand us who to center on.
// Read once at mount so the focus→URL sync below doesn't trigger a refetch.
const initialFocus = useRef<string | null>(searchParams.get("focus"));
const [persons, setPersons] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [given, setGiven] = useState(""); const [rels, setRels] = useState<Relationship[]>([]);
const [surname, setSurname] = useState(""); const [events, setEvents] = useState<Event[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [results, setResults] = useState<Person[] | null>(null); // server fuzzy search
const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name.
// `key` keeps each empty slot's inline form independent (a person has 2
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
const [addName, setAddName] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
}); });
if (response.status === 401) { if (p.response.status === 401) {
router.push("/login"); router.push("/login");
return; return;
} }
setPersons(data ?? []); const [r, e, t] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
]);
const ppl = p.data ?? [];
const home = t.data?.home_person_id ?? null;
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current)
? initialFocus.current
: null;
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null);
setReady(true); setReady(true);
}, [router, treeId]); }, [router, treeId]);
@@ -38,60 +76,434 @@ export default function TreeDetailPage() {
load(); load();
}, [load]); }, [load]);
async function addPerson(e: React.FormEvent) { // Keep the focused person in the URL (?focus=…) so leaving and returning —
e.preventDefault(); // e.g. opening a person then coming back — lands on the same person rather
if (!given.trim() && !surname.trim()) return; // than resetting to the home person. `replace` keeps history clean.
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", { useEffect(() => {
params: { path: { tree_id: treeId } }, if (!focusId || searchParams.get("focus") === focusId) return;
body: { given: given || null, surname: surname || null }, const sp = new URLSearchParams(searchParams.toString());
}); sp.set("focus", focusId);
if (!error) { router.replace(`/trees/${treeId}?${sp.toString()}`, { scroll: false });
setGiven(""); }, [focusId, searchParams, router, treeId]);
setSurname("");
load(); // Debounced server-side fuzzy search (pg_trgm) across the whole tree.
useEffect(() => {
const q = search.trim();
if (!q) {
setResults(null);
return;
} }
const t = setTimeout(async () => {
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { q } },
});
setResults(data ?? []);
}, 250);
return () => clearTimeout(t);
}, [search, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
// Order parents deterministically: father (male) on top, mother below, with a
// stable fallback when gender is unknown (so it doesn't depend on which link
// happened to be created first).
const parentRank = (id: string) => {
const g = byId.get(id)?.gender;
return g === "male" ? 0 : g === "female" ? 1 : 2;
};
const parentsOf = (id: string) =>
rels
.filter((r) => r.type === "parent_child" && r.person_to_id === id)
.map((r) => r.person_from_id)
.sort((a, b) => parentRank(a) - parentRank(b));
const childrenOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
const partnersOf = (id: string) =>
rels
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
const years = useMemo(() => {
const m = new Map<string, string>();
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
for (const p of people) {
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}${parts[1]}`.replace(/^$/, ""));
}
return m;
}, [people, events]);
async function addPerson(name: string): Promise<string | null> {
const { given, surname } = splitName(name);
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
body: { given, surname },
});
return data?.id ?? null;
}
// Create a new (blank) person and open their page to fill in details.
async function newPersonAndGo() {
const id = await addPerson("");
if (id) router.push(`/trees/${treeId}/persons/${id}`);
}
async function createFirst(e: React.FormEvent) {
e.preventDefault();
if (!firstName.trim()) return;
const id = await addPerson(firstName);
setFirstName("");
if (id) setFocusId(id);
load();
}
async function postRel(body: components["schemas"]["RelationshipCreate"]) {
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
// Create the relationship(s) connecting an (existing or new) person to anchor.
async function createLink(kind: AddKind, anchor: string, personId: string) {
if (kind === "parent") {
await postRel({ type: "parent_child", person_from_id: personId, person_to_id: anchor, qualifier: "biological" });
} else if (kind === "partner") {
await postRel({ type: "partnership", person_from_id: anchor, person_to_id: personId });
} else {
// child: link to anchor, and to anchor's spouse too (so both parents show)
await postRel({ type: "parent_child", person_from_id: anchor, person_to_id: personId, qualifier: "biological" });
const partners = partnersOf(anchor);
if (partners.length === 1) {
await postRel({ type: "parent_child", person_from_id: partners[0], person_to_id: personId, qualifier: "biological" });
}
}
}
async function linkExisting(personId: string) {
if (!adding) return;
await createLink(adding.kind, adding.anchor, personId);
setAdding(null);
setAddName("");
load();
}
async function submitAdd(e: React.FormEvent) {
e.preventDefault();
if (!adding || !addName.trim()) return;
const newId = await addPerson(addName);
if (newId) await createLink(adding.kind, adding.anchor, newId);
setAdding(null);
setAddName("");
load();
} }
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return ( if (people.length === 0) {
<div className="space-y-6"> return (
<h1 className="text-2xl font-semibold">People</h1> <div className="space-y-6">
<h1 className="text-2xl font-semibold">Start your tree</h1>
<Card>
<CardContent className="p-6">
<form onSubmit={createFirst} className="flex flex-wrap gap-2">
<Input
className="w-64"
placeholder="First person's full name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<Button type="submit">Add person</Button>
</form>
</CardContent>
</Card>
</div>
);
}
const focus = focusId ? byId.get(focusId) : undefined;
if (!focus) {
setFocusId(people[0].id);
return null;
}
const PersonBox = ({
id,
muted,
}: {
id: string;
muted?: boolean;
}) => {
const p = byId.get(id);
if (!p) return null;
const isFocus = id === focusId;
return (
<button
onClick={() => setFocusId(id)}
className={`w-44 rounded-lg border px-3 py-2 text-left transition-colors ${
isFocus
? "border-bronze bg-bronze/[0.08]"
: "border-[var(--border)] bg-[var(--surface)] hover:border-bronze/60"
} ${muted ? "opacity-90" : ""}`}
>
<div className="truncate text-sm font-medium">{p.primary_name ?? "Unnamed"}</div>
<div className="text-xs text-[var(--muted)]">{years.get(id) ?? "—"}</div>
</button>
);
};
const AddSlot = ({
formKey,
kind,
anchor,
label,
}: {
formKey: string;
kind: AddKind;
anchor: string;
label: string;
}) =>
adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-56 flex-col gap-1">
<Input
autoFocus
className="h-9"
placeholder="Search existing or type a new name"
value={addName}
onChange={(e) => setAddName(e.target.value)}
/>
{addName.trim() && (
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
{people
.filter(
(p) =>
p.id !== anchor &&
(p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
)
.slice(0, 6)
.map((p) => (
<button
key={p.id}
type="button"
onClick={() => linkExisting(p.id)}
className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-bronze/[0.07]"
>
<span className="truncate">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
</button>
))}
<button
type="submit"
className="flex w-full items-center gap-1 border-t border-[var(--border)] px-2 py-1.5 text-left text-bronze hover:bg-bronze/[0.07]"
>
+ Create new {addName.trim()}
</button>
</div>
)}
<button type="button" onClick={() => setAdding(null)} className="text-xs text-[var(--muted)]">
cancel
</button>
</form>
) : (
<button
onClick={() => {
setAdding({ key: formKey, kind, anchor });
setAddName("");
}}
className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
>
+ {label}
</button>
);
// Recursive ancestor chart (grows rightward): a node is its box plus a
// two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 =
// focus, capped at grandparents (depth 2).
const renderNode = (
slotPersonId: string | null,
childId: string,
keyPrefix: string,
depth: number,
): React.ReactNode => {
const box = slotPersonId ? (
<PersonBox id={slotPersonId} muted={depth > 0} />
) : (
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
);
if (!slotPersonId || depth >= 2) {
return <div className="ped-person">{box}</div>;
}
const ps = parentsOf(slotPersonId);
return (
<div className="ped-person">
<div className="ped-self">{box}</div>
<div className="ped-branch">
<div className="ped-leaf">
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
</div>
<div className="ped-leaf">
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
</div>
</div>
</div>
);
};
const partners = partnersOf(focus.id);
const children = childrenOf(focus.id);
// "Dangling" people: not linked to anyone. Common after a GEDCOM import or a
// mistaken delete — surface them so they're not lost in the directory.
const connected = new Set<string>();
for (const r of rels) {
connected.add(r.person_from_id);
connected.add(r.person_to_id);
}
const unconnected = people
.filter((p) => !connected.has(p.id))
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""));
const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
);
// Server fuzzy results when searching; otherwise the loaded set.
const directory = results ?? sorted;
const shown = directory.slice(0, 200); // cap DOM nodes; refine search to narrow
return (
<div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Family view</h1>
<div className="flex items-center gap-3">
<Button size="sm" onClick={newPersonAndGo}>
+ Add person
</Button>
<Link
href={`/trees/${treeId}/persons/${focus.id}?from=people`}
className="text-sm text-bronze hover:underline"
>
Open {focus.primary_name ?? "person"}
</Link>
</div>
</div>
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
<Card> <Card>
<CardHeader> <CardContent className="overflow-x-auto p-6">
<CardTitle className="text-base">Add a person</CardTitle> <div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
</CardHeader>
<CardContent>
<form onSubmit={addPerson} className="flex gap-2">
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
<Button type="submit">Add</Button>
</form>
</CardContent> </CardContent>
</Card> </Card>
<div> {/* Family group: partners + children of the focus */}
<h2 className="mb-2 text-lg font-semibold">People</h2> <div className="grid gap-5 sm:grid-cols-2">
{persons.length === 0 ? ( <Card>
<p className="text-[var(--muted)]">No people yet.</p> <CardContent className="space-y-3 p-6">
) : ( <h2 className="font-serif text-base font-semibold">Spouses &amp; partners</h2>
<ul className="space-y-2"> <div className="flex flex-wrap gap-3">
{persons.map((person) => ( {partners.map((id) => (
<li key={person.id}> <PersonBox key={id} id={id} muted />
<Link href={`/trees/${treeId}/persons/${person.id}`}> ))}
<Card className="transition-colors hover:border-bronze/50"> <AddSlot
<CardContent className="p-4"> formKey={`partner-${focus.id}`}
{person.primary_name ?? ( kind="partner"
<span className="text-[var(--muted)]">Unnamed</span> anchor={focus.id}
)} label="add spouse"
</CardContent> />
</Card> </div>
</Link> </CardContent>
</li> </Card>
))}
</ul> <Card>
)} <CardContent className="space-y-3 p-6">
<h2 className="font-serif text-base font-semibold">Children</h2>
<div className="flex flex-wrap gap-3">
{children.map((id) => (
<PersonBox key={id} id={id} muted />
))}
<AddSlot
formKey={`child-${focus.id}`}
kind="child"
anchor={focus.id}
label="add child"
/>
</div>
</CardContent>
</Card>
</div>
{/* Unconnected people — not linked to anyone in the tree */}
{unconnected.length > 0 && (
<Card className="border-bronze/40">
<CardContent className="space-y-3 p-6">
<div className="flex items-center justify-between">
<h2 className="font-serif text-base font-semibold">
Not connected to anyone ({unconnected.length})
</h2>
<span className="text-xs text-[var(--muted)]">
Open one and add a relationship, or delete it.
</span>
</div>
<div className="flex flex-wrap gap-3">
{unconnected.slice(0, 60).map((p) => (
<div key={p.id} className="flex items-center gap-1">
<PersonBox id={p.id} muted />
<Link
href={`/trees/${treeId}/persons/${p.id}?from=people`}
className="text-xs text-bronze hover:underline"
>
open
</Link>
</div>
))}
</div>
{unconnected.length > 60 && (
<p className="text-xs text-[var(--muted)]">
Showing 60 of {unconnected.length}.
</p>
)}
</CardContent>
</Card>
)}
{/* Scrollable, searchable people directory (scales to large trees) */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="font-serif text-base font-semibold">People ({people.length})</h2>
<Input
className="w-64"
placeholder="Search by name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Card className="overflow-hidden">
<div className="max-h-96 overflow-y-auto">
{shown.length === 0 ? (
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
) : (
shown.map((p, i) => (
<button
key={p.id}
onClick={() => setFocusId(p.id)}
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
i > 0 ? "border-t border-[var(--border)]" : ""
} ${p.id === focusId ? "bg-bronze/[0.08]" : "hover:bg-bronze/[0.05]"}`}
>
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">
{years.get(p.id) ?? ""}
</span>
</button>
))
)}
</div>
{directory.length > shown.length && (
<div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]">
Showing {shown.length} of {directory.length} refine your search to narrow.
</div>
)}
</Card>
</div> </div>
</div> </div>
); );
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Person = components["schemas"]["PersonRead"];
export default function RecoveryPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [people, setPeople] = useState<Person[]>([]);
const [ready, setReady] = useState(false);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { deleted: true } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setPeople(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/restore", {
params: { path: { tree_id: treeId, person_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Recently deleted</h1>
<p className="text-sm text-[var(--muted)]">
Deleted people are recoverable for 30 days, then permanently purged.
</p>
{people.length === 0 ? (
<p className="text-[var(--muted)]">Nothing here.</p>
) : (
<ul className="space-y-2">
{people.map((p) => (
<li key={p.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{p.primary_name ?? "Unnamed"}</span>
<Button variant="outline" size="sm" onClick={() => restore(p.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
+885
View File
@@ -0,0 +1,885 @@
.f3 {
--female-color: rgb(196, 138, 146);
--male-color: rgb(120, 159, 172);
--genderless-color: lightgray;
--background-color: rgb(33, 33, 33);
--text-color: #fff;
font-family: 'Roboto', sans-serif;
}
.f3 * {
box-sizing: border-box;
}
.f3 .cursor-pointer {
cursor: pointer;
}
.f3 svg.main_svg {
width: 100%;
height: 100%;
}
.f3 svg.main_svg text {
fill: currentColor;
}
.f3 rect.card-female, .f3 .card-female .card-body-rect, .f3 .card-female .text-overflow-mask {
fill: var(--female-color);
}
.f3 rect.card-male, .f3 .card-male .card-body-rect, .f3 .card-male .text-overflow-mask {
fill: var(--male-color);
}
.f3 .card-genderless .card-body-rect, .f3 .card-genderless .text-overflow-mask {
fill: var(--genderless-color);
}
.f3 .card_add .card-body-rect {
fill: #3b5560;
stroke-width: 4px;
stroke: #fff;
cursor: pointer;
}
.f3 g.card_add text {
fill: #fff;
}
.f3 .card-main-outline {
stroke: currentColor;
stroke-width: 3px;
}
.f3 .card_family_tree rect {
transition: 0.3s;
}
.f3 .card_family_tree:hover rect {
transform: scale(1.1);
}
.f3 .card_add_relative {
cursor: pointer;
color: #fff;
transition: 0.3s;
}
.f3 .card_add_relative circle {
fill: rgba(0, 0, 0, 0);
}
.f3 .card_add_relative:hover {
color: black;
}
.f3 .card_edit.pencil_icon {
color: #fff;
transition: 0.3s;
}
.f3 .card_edit.pencil_icon:hover {
color: black;
}
.f3 .card_break_link, .f3 .link_upper, .f3 .link_lower, .f3 .link_particles {
transform-origin: 50% 50%;
transition: 1s;
}
.f3 .card_break_link {
color: #fff;
}
.f3 .card_break_link.closed .link_upper {
transform: translate(-140.5px, 655.6px);
}
.f3 .card_break_link.closed .link_upper g {
transform: rotate(-58deg);
}
.f3 .card_break_link.closed .link_particles {
transform: scale(0);
}
.f3 .input-field input {
height: 2.5rem !important;
}
.f3 .input-field > label:not(.label-icon).active {
-webkit-transform: translateY(-8px) scale(0.8);
transform: translateY(-8px) scale(0.8);
}
.f3.f3-cont {
width:100%;
height:900px;
max-height:70vh;
background-color: var(--background-color);
color: var(--text-color);
}
.f3 {
position: relative;
display: flex;
}
/* form-info */
.f3-form input[type="text"],
.f3-form textarea,
.f3-form select {
width: 100%;
padding: 8px 12px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
background: var(--background-color);
color: currentColor;
}
.f3-form input[type="text"]:focus,
.f3-form textarea:focus,
.f3-form select:focus {
box-shadow: 0 0 5px rgba(76, 175, 80, 0.2);
}
.f3-form button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin: 10px 0;
transition: background-color 0.3s ease-in-out, border-color 0.3s ease-in-out, color 0.3s ease-in-out;
}
.f3-form button[type="submit"] {
background-color: #4CAF50;
color: white;
}
.f3-cancel-btn {
background-color: #ccc;
}
.f3-form .f3-delete-btn {
background-color: transparent;
border: 1px solid #f44336;
color: #f44336;
width: 100%;
padding: 5px 10px;
}
.f3-delete-btn:hover {
background-color: #da190b;
border-color: #da190b;
color: #fff;
}
.f3-delete-btn:disabled {
opacity: 0.5;
background-color: transparent;
color: #f44336;
cursor: not-allowed;
}
.f3-form .f3-remove-relative-btn {
background-color: transparent;
border: 1px solid currentColor;
color: currentColor;
width: 100%;
padding: 5px 10px;
}
.f3-remove-relative-btn:hover, .f3-remove-relative-btn.active {
background-color: var(--text-color);
border-color: var(--text-color);
color: var(--background-color);
}
.f3-radio-group {
margin: 15px 0;
}
.f3-radio-group label {
margin-right: 15px;
cursor: pointer;
}
.f3-radio-group input[type="radio"] {
margin-right: 5px;
}
.f3-info-field-label, .f3-form-field label {
font-weight: bold;
font-size: 12px;
display: block;
opacity: 0.8;
}
.f3-info-field-value {
font-weight: normal;
display: block;
border: none;
outline: none;
border-bottom: 1px solid rgba(255,255,255,0.2);
padding-bottom: 1px;
margin-bottom: 10px;
min-height: 18px;
}
.f3-form-buttons {
text-align: right;
}
.f3-form-title {
text-align: center;
}
.f3-form.non-editable .f3-form-buttons,
.f3-form.non-editable .f3-delete-btn,
.f3-form.non-editable .f3-remove-relative-btn,
.f3-form.non-editable .f3-link-existing-relative {
display: none;
}
.f3-close-btn {
cursor: pointer;
position: absolute;
left: 10px;
top: 8px;
font-size: 30px;
color: var(--text-color);
}
.f3-edit-btn {
position: relative;
top: -1px;
width: 24px;
height: 24px;
cursor: pointer;
display: inline-block;
}
.f3-add-relative-btn {
cursor: pointer;
width: 27px;
height: 27px;
margin-right: 5px;
display: inline-block;
}
/* card-html */
.f3 div.card {
cursor: pointer;
color: var(--text-color);
position: relative;
line-height: 1.2;
}
.f3 div.card-image-circle {
border-radius: 50%;
padding: 5px;
width: 90px;
height: 90px;
}
.f3 div.card-image-circle div.card-label {
position: absolute;
bottom: -10px;
left: 50%;
transform: translate(-50%, 50%);
max-width: 150%;
min-height: 22px;
text-align: center;
background-color: rgba(0, 0, 0, 0.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 3px;
padding: 0 5px;
}
.f3 div.card-image-circle img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-image-circle svg {
width: 100%;
height: 100%;
padding: 5px;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-image-circle img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-rect {
padding: 5px;
border-radius: 3px;
width: 120px;
min-height: 70px;
overflow: hidden;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
}
.f3 div.card-image-rect {
width: 200px;
min-height: 70px;
display: flex;
align-items: center;
border-radius: 5px;
}
.f3 div.card-image-rect .person-icon {
height: 70px;
width: 70px;
object-fit: cover;
flex: 0 0 auto;
padding: 5px;
margin-right: 10px;
}
.f3 div.card-image-rect img {
height: 70px;
width: 70px;
object-fit: cover;
flex: 0 0 auto;
padding: 5px;
margin-right: 10px;
border-radius: 8px;
}
.f3 div.card-image-rect svg {
object-fit: cover;
width: 100%;
height: 100%;
padding: 5px;
border-radius: 7px;
}
.f3 div.card-image-rect div.card-label {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
}
.f3 div.mini-tree {
text-align: right;
position: absolute;
top: -15px;
right: -2px;
z-index: -1;
}
.f3 div.mini-tree svg {
width: 55px;
}
.f3 .f3-card-duplicate-tag {
position: absolute;
top: 2px;
right: 2px;
color: rgb(255, 251, 220);
background-color: rgba(255, 251, 220, 0);
border-radius: 50%;
padding: 2px;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
}
.f3 .f3-card-duplicate-hover div.card-inner {
transform: translate(0, -2px);
outline: 4px solid rgb(255, 251, 220);
}
.f3 .f3-card-duplicate-hover .f3-card-duplicate-tag {
background-color: rgba(255, 251, 220, .8);
color: #000;
}
.f3 .f3-remove-relative-active .card {
background-color: var(--background-color);
}
.f3 .f3-remove-relative-active .card-inner {
transition: border 0.2s ease-in-out, opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
opacity: .75;
}
.f3 .f3-remove-relative-active .card:hover .card-inner {
opacity: .25;
}
.f3 .f3-remove-relative-active .card-male.card-depth--1:hover .card-inner {
transform: translate(-8px, -8px);
}
.f3 .f3-remove-relative-active .card.card-female.card-depth--1:hover .card-inner {
transform: translate(8px, -8px);
}
.f3 .f3-remove-relative-active .card.card-female.card-depth-0:hover .card-inner {
transform: translate(8px, 0);
}
.f3 .f3-remove-relative-active .card.card-male.card-depth-0:hover .card-inner {
transform: translate(-8px, 0);
}
.f3 .f3-remove-relative-active .card.card-depth-1:hover .card-inner {
transform: translate(0, 8px);
}
.f3 .f3-remove-relative-active .card.card-main .card-inner {
transform: translate(0, 0)!important;
opacity: 1!important;
}
.f3 div.card > div {
transition: transform 0.2s ease-in-out;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.8);
}
.f3 .card-inner {
outline: 0px solid rgba(255, 255, 255, 1);
transition: outline 0.5s ease-in-out;
}
.f3 div.card-female .card-inner, .f3 div.card-female .person-icon svg {
background-color: var(--female-color);
}
.f3 div.card-male .card-inner, .f3 div.card-male .person-icon svg {
background-color: var(--male-color);
}
.f3 div.card-genderless .card-inner, .f3 div.card-genderless .person-icon svg {
background-color: var(--genderless-color);
}
.f3 div.card-new-rel .card-inner, .f3 div.card-new-rel .person-icon svg {
background-color: var(--background-color);
}
.f3 div.card-to-add .card-inner {
background-color: var(--background-color);
border: 1px solid;
}
.f3 div.card-to-add .card-inner .card-label {
margin: 0 auto;
}
.f3 div.card-to-add .person-icon {
display: none;
}
.f3 div.card-new-rel .card-inner {
border-width: 1px;
border-style: dashed;
outline: 0px !important;
}
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-to-add.card-female .card-inner {
border-color: var(--female-color);
color: var(--female-color);
}
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-to-add.card-male .card-inner {
color: var(--male-color);
border-color: var(--male-color);
}
.f3 div.card-unknown .card-inner {
background-color: var(--background-color);
border: 1px solid;
}
.f3 div.card-unknown .card-inner .card-label {
margin: 0 auto;
}
.f3 div.card-unknown .person-icon {
display: none;
}
.f3 div.card-new-rel .card-inner {
border-width: 1px;
border-style: dashed;
outline: 0px !important;
}
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-unknown.card-female .card-inner {
border-color: var(--female-color);
color: var(--female-color);
}
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-unknown.card-male .card-inner {
color: var(--male-color);
border-color: var(--male-color);
}
.f3 div.card:hover > div {
transform: translate(0, -2px);
}
.f3 div.card-main .card-inner, .f3 div.card:hover .card-inner {
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.8);
}
.f3 div.card-main .card-inner {
outline: 4px solid rgba(220, 220, 220, 1);
}
.f3 div.card-inner.f3-path-to-main {
outline: 4px solid rgba(255, 255, 255, 1);
}
.f3 .link {
transition: stroke-width 0.2s ease-in-out;
/* Brand-aware connector lines: dark on the light paper, light on dark. The
library's default white stroke is invisible on the light theme. */
fill: none;
stroke: var(--line);
stroke-width: 1.5px;
}
.f3 .link.f3-path-to-main {
stroke-width: 4px;
}
.f3-form-cont {
position: relative;
z-index: 6;
right: 0;
top: 0;
width: 0;
height: 100%;
background-color: var(--background-color);
overflow: auto;
flex: 0 0 auto;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5);
}
.f3-form-cont.opened {
width: 350px;
}
.f3-form {
padding: 20px;
}
.f3-form hr {
border-style: solid;
border-width: thin 0 0 0;
opacity: 0.15;
}
.f3-nav-cont {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
}
.f3-history-controls {
padding: 8px 5px 7px 9px;
display: inline-block;
position: relative;
z-index: 2;
}
.f3-back-button, .f3-forward-button {
width: 30px;
height: 30px;
transition: opacity 0.3s ease;
cursor: pointer;
display: inline-block;
background-color: transparent;
border: none;
margin-right: 10px;
color: currentColor;
}
.f3-history-controls svg {
height: 100%;
}
.f3-back-button.disabled, .f3-forward-button.disabled {
opacity: 0.5;
}
.f3-modal {
display: none;
position: absolute;
z-index: 10;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.4);
}
.f3-modal-content {
position: relative;
background-color: var(--background-color);
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
border-radius: 5px;
width: 500px;
max-width: 90%;
}
.f3-modal-close {
color: #aaa;
position: absolute;
right: 10px;
top: 7px;
font-size: 28px;
font-weight: bold;
}
.f3-modal-close:hover,
.f3-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.f3-popup {
position: fixed;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.8);
}
.f3-popup-content {
position: relative;
background-color: var(--background-color);
border: 1px solid #888;
border-radius: 5px;
overflow: hidden;
width: 100%;
height: 100%;
}
.f3-popup-nav {
height: 20px;
}
.f3-popup-content-inner {
width: 100%;
height: 100%;
}
.f3-popup-close {
color: #aaa;
position: absolute;
z-index: 4;
right: 6px;
top: 1px;
font-size: 28px;
font-weight: bold;
line-height: 1;
}
.f3-popup-close:hover,
.f3-popup-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.f3-btn {
position: relative;
cursor: pointer;
padding: 5px 10px;
overflow: hidden;
border-width: 0;
outline: none;
border-radius: 3px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .6);
background-color: var(--text-color);
color: var(--background-color);
transition: background-color .3s;
font-size: 14px;
}
.f3-btn:hover, .f3-btn:focus {
background-color: var(--background-color);
color: var(--text-color);
}
.f3-female-bg {
background-color: var(--female-color);
}
.f3-male-bg {
background-color: var(--male-color);
}
.f3-genderless-bg {
background-color: var(--genderless-color);
}
.f3-female-color {
color: var(--female-color);
}
.f3-male-color {
color: var(--male-color);
}
.f3-genderless-color {
color: var(--genderless-color);
}
.f3-autocomplete-cont {
position: relative;
display: inline-block;
z-index: 2;
font-size: 14px;
width: 200px;
}
.f3-autocomplete input {
border: 1px solid rgba(255, 255, 255, 0.2);
background-color: var(--background-color);
color: var(--text-color);
padding: 10px;
width: 100%;
}
.f3-autocomplete input:focus {
outline: none;
}
.f3-autocomplete-toggle {
position: absolute;
right: 10px;
top: 10px;
cursor: pointer;
color: var(--text-color);
transition: color 0.3s ease-in-out;
width: 20px;
}
.f3-autocomplete-items {
border: 1px solid rgba(255, 255, 255, 0.2);
border-top: none;
overflow-y: auto;
max-height: 0;
background-color: var(--background-color);
transition: max-height 0.3s ease-in-out;
}
.f3-autocomplete.active .f3-autocomplete-items {
max-height: 300px;
}
.f3-autocomplete-item > div {
padding: 10px;
cursor: pointer;
background-color: var(--background-color);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
}
.f3-autocomplete-item > div:hover, .f3-autocomplete-item.f3-selected > div {
background-color: var(--text-color);
color: var(--background-color);
}
.f3-autocomplete-active {
background-color: DodgerBlue !important;
color: #ffffff;
}
.f3-kinship-info {
padding: 10px 20px;
}
.f3-kinship-info .f3-info-field {
color:#b3b01e
}
.f3-kinship-info-icon {
cursor:pointer;
display:inline-block;
width:18px;
height:18px;
color:#04a4f4;
position:relative;
top:4px;
left:2px;
}
.f3-kinship-info .f3 {
width:100%;
height: 100%;
position:relative;
background-color:rgb(33,33,33);
color:#fff;
}
.f3 .f3-kinship-info .card-kinship-self {
min-height: 0px;
width: 60px;
height: 60px;
border-radius: 50%;
background-color: var(--background-color) !important;
border: solid 3px;
color: #437fae;
font-weight: bold;
}
.f3 .f3-kinship-info .card-kinship-self.f3-real-label {
width: 150px;
height: 50px;
border-radius: 50px;
}
.f3 .f3-kinship-info .card-kinship-rel {
min-height: 0px;
width: 150px;
height: 50px;
border-radius: 50px;
background-color: #1d3456 !important;
font-weight: bold;
}
.f3 .f3-kinship-info .card-kinship-default {
min-height: 0px;
width: 150px;
height: 50px;
border-radius: 50px;
background-color: var(--background-color) !important;
border: solid 1px;
}
.f3-kinship-labels-toggle {
position: absolute;
top: 0;
left: 0;
z-index: 10;
font-size: 12px;
}
.f3-kinship-labels-toggle label {
cursor: pointer;
color: #fff;
font-weight: bold;
text-align: center;
padding: 2px 5px;
}
.f3-kinship-labels-toggle input[type="checkbox"] {
cursor: pointer;
margin-left: 5px;
margin-right: 5px;
margin-top: 5px;
margin-bottom: 5px;
}
+412
View File
@@ -0,0 +1,412 @@
"use client";
// Vendored from family-chart/dist/styles (the package blocks the CSS subpath export).
import "./chart.css";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FanChart } from "@/components/fan-chart";
import { DepthControl } from "@/components/depth-control";
type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
type Mode = "landscape" | "portrait" | "fan";
function splitName(name: string | null | undefined): [string, string] {
const t = (name ?? "").trim().split(/\s+/).filter(Boolean);
if (t.length <= 1) return [name ?? "", ""];
return [t.slice(0, -1).join(" "), t[t.length - 1]];
}
export default function TreePage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const searchParams = useSearchParams();
const treeId = params.id;
// The focused person can arrive in the URL (?focus=…) — e.g. coming back from
// a person page. Captured once at mount so syncing focus→URL doesn't refetch.
const initialFocus = useRef<string | null>(searchParams.get("focus"));
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null);
const [query, setQuery] = useState("");
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
const [focusId, setFocusId] = useState<string | null>(null);
// The tree's default/home person — lets us offer a "recenter on default" jump.
const [homeId, setHomeId] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>("landscape");
const [renderNote, setRenderNote] = useState<string | null>(null);
// How many generations to show around the focus, each independently settable
// (or "all"). ALL_DEPTH is just a number bigger than any real lineage; the
// chart only renders people that exist, so a high cap costs nothing.
const [ancDepth, setAncDepth] = useState(3); // ancestors (backwards)
const [progDepth, setProgDepth] = useState(2); // descendants (forwards)
const [ancAll, setAncAll] = useState(false);
const [progAll, setProgAll] = useState(false);
const ALL_DEPTH = 100;
const effAnc = ancAll ? ALL_DEPTH : ancDepth;
const effProg = progAll ? ALL_DEPTH : progDepth;
useEffect(() => {
let cancelled = false;
(async () => {
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
if (p.response.status === 401) {
router.push("/login");
return;
}
const [r, e, t] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
]);
if (cancelled) return;
const ppl = p.data ?? [];
const home = t.data?.home_person_id ?? null;
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setHomeId(homeId);
// Honor an explicit ?focus first (came from a person page / a shared
// link), then the tree's default/home person, then the first person.
const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current)
? initialFocus.current
: null;
setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null);
setStatus(ppl.length ? "ready" : "empty");
})().catch(() => !cancelled && setStatus("error"));
return () => {
cancelled = true;
};
}, [router, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
const parentsOf = useCallback(
(id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
[rels],
);
const childrenOf = useCallback(
(id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id),
[rels],
);
const partnersOf = useCallback(
(id: string) =>
rels
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id))
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)),
[rels],
);
const years = useMemo(() => {
const m = new Map<string, string>();
for (const ev of events) {
if (ev.person_id && ev.event_type === "birth" && !m.has(ev.person_id)) {
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
if (y) m.set(ev.person_id, y);
}
}
return m;
}, [events]);
const nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "Unknown", [byId]);
const yearOf = useCallback((id: string) => years.get(id) ?? "", [years]);
// family-chart for landscape/portrait. Intentionally not keyed on focusId —
// card clicks recenter via updateMainId without rebuilding the chart.
useEffect(() => {
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
let cancelled = false;
(async () => {
// Sanitize the graph before handing it to family-chart, which recurses
// through parents and will blow the stack (blank tree) on a cycle — e.g. a
// person edited into being their own ancestor.
const alive = new Set(people.map((pp) => pp.id));
const ok = (ids: string[], self: string) =>
[...new Set(ids)].filter((id) => alive.has(id) && id !== self);
// Build an acyclic set of parent edges: skip any edge that would make a
// person their own ancestor. Children are derived from the kept edges so
// parent/child stays consistent.
const parentsMap = new Map<string, string[]>();
const childrenMap = new Map<string, string[]>();
let dropped = 0;
const isAncestorOf = (ancestor: string, of: string): boolean => {
const stack = [...(parentsMap.get(of) ?? [])];
const seen = new Set<string>();
while (stack.length) {
const n = stack.pop()!;
if (n === ancestor) return true;
if (seen.has(n)) continue;
seen.add(n);
for (const p of parentsMap.get(n) ?? []) stack.push(p);
}
return false;
};
for (const pp of people) {
const accepted: string[] = [];
for (const par of ok(parentsOf(pp.id), pp.id)) {
// Edge "pp has parent par" loops if pp is already an ancestor of par.
if (isAncestorOf(pp.id, par)) {
dropped++;
continue;
}
accepted.push(par);
parentsMap.set(pp.id, accepted);
childrenMap.set(par, [...(childrenMap.get(par) ?? []), pp.id]);
}
parentsMap.set(pp.id, accepted);
}
const data = people.map((pp) => {
const [fn, ln] = splitName(pp.primary_name);
return {
id: pp.id,
data: {
"first name": fn || "Unnamed",
"last name": ln,
birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
},
rels: {
spouses: ok(partnersOf(pp.id), pp.id),
parents: parentsMap.get(pp.id) ?? [],
children: childrenMap.get(pp.id) ?? [],
},
};
});
const f3 = await import("family-chart");
if (cancelled || !containerRef.current) return;
try {
containerRef.current.innerHTML = "";
const chart = f3.createChart(containerRef.current, data);
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
if (mode === "portrait") chart.setOrientationVertical();
else chart.setOrientationHorizontal();
// Generations to show around the focus (configurable; see depth controls).
chart.setAncestryDepth?.(effAnc);
chart.setProgenyDepth?.(effProg);
// Default card click recenters the whole hourglass; sync focus for the
// "Open profile" link after every (re)build.
chart.setAfterUpdate?.(() => {
const md = chart.getMainDatum?.();
const id = md?.id ?? md?.data?.id;
if (id) setFocusId(id);
});
chartRef.current = chart;
if (focusId) chart.updateMainId(focusId);
chart.updateTree({ initial: true });
setRenderNote(
dropped > 0
? `Skipped ${dropped} conflicting parent link${dropped === 1 ? "" : "s"} (a person can't be their own ancestor). Open the people involved to fix the relationship.`
: null,
);
} catch (err) {
// Never leave a blank canvas — show a message and let them fix via the
// Family view / person pages.
console.error("tree render failed", err);
if (containerRef.current) containerRef.current.innerHTML = "";
setRenderNote(
"The tree couldn't be drawn — a relationship may be conflicting. Use the Family view to open the affected people and check their parents/children.",
);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, mode, people, rels, events]);
// Apply depth changes to the already-built chart without a full rebuild
// (landscape/portrait only; the fan reads its own `generations` prop).
useEffect(() => {
if (mode === "fan" || !chartRef.current) return;
chartRef.current.setAncestryDepth?.(effAnc);
chartRef.current.setProgenyDepth?.(effProg);
chartRef.current.updateTree?.();
}, [effAnc, effProg, mode]);
// Jump the tree (or fan) to a person and rebuild the hourglass around them.
const goTo = useCallback(
(id: string) => {
setFocusId(id);
setQuery("");
if (mode !== "fan" && chartRef.current) {
chartRef.current.updateMainId?.(id);
chartRef.current.updateTree?.();
}
},
[mode],
);
// Mirror the focused person into the URL (?focus=…) so navigating away and
// back — or sharing the link — keeps the tree centered where you left it.
// `replace` (not push) so each recenter doesn't pile up in browser history.
useEffect(() => {
if (!focusId || searchParams.get("focus") === focusId) return;
const sp = new URLSearchParams(searchParams.toString());
sp.set("focus", focusId);
router.replace(`/trees/${treeId}/tree?${sp.toString()}`, { scroll: false });
}, [focusId, searchParams, router, treeId]);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return people
.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
.slice(0, 8);
}, [query, people]);
const ModeButton = ({ m, label }: { m: Mode; label: string }) => (
<button
onClick={() => setMode(m)}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === m ? "bg-bronze text-paper" : "text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{label}
</button>
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">Tree</h1>
<div className="relative">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find a person…"
className="w-56"
/>
{matches.length > 0 && (
<ul className="absolute z-20 mt-1 w-72 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{matches.map((p) => (
<li key={p.id}>
<button
onClick={() => goTo(p.id)}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
>
<span>{p.primary_name ?? "Unnamed"}</span>
{yearOf(p.id) && (
<span className="text-xs text-[var(--muted)]">{yearOf(p.id)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
{homeId && focusId !== homeId && (
<button
onClick={() => goTo(homeId)}
className="text-sm text-bronze hover:underline"
title={`Recenter on the tree's default person (${nameOf(homeId)})`}
>
Back to default person
<span className="text-[var(--muted)]"> · {nameOf(homeId)}</span>
</button>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
<ModeButton m="landscape" label="Landscape" />
<ModeButton m="portrait" label="Portrait" />
<ModeButton m="fan" label="Fan" />
</div>
{focusId && (
<Link
href={`/trees/${treeId}/persons/${focusId}?from=tree`}
className="text-sm text-bronze hover:underline"
>
Open {nameOf(focusId)}
</Link>
)}
</div>
</div>
{status === "ready" && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5">
<span className="text-sm font-medium">Generations</span>
<DepthControl
label="Ancestors"
icon="↑"
value={ancDepth}
all={ancAll}
onValue={(v) => {
setAncAll(false);
setAncDepth(v);
}}
onAll={setAncAll}
/>
<DepthControl
label="Descendants"
icon="↓"
value={progDepth}
all={progAll}
onValue={(v) => {
setProgAll(false);
setProgDepth(v);
}}
onAll={setProgAll}
disabled={mode === "fan"}
/>
{mode === "fan" && (
<span className="text-xs text-[var(--muted)]">Fan shows ancestors only.</span>
)}
</div>
)}
{status === "empty" && (
<p className="text-[var(--muted)]">No people yet add some under People, or import a GEDCOM.</p>
)}
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
{renderNote && mode !== "fan" && (
<p className="rounded-md border border-bronze/40 bg-bronze/[0.06] px-3 py-2 text-sm text-bronze">
{renderNote}
</p>
)}
{status === "ready" && mode === "fan" && focusId ? (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
<FanChart
focusId={focusId}
parentsOf={parentsOf}
nameOf={nameOf}
yearOf={yearOf}
onSelect={setFocusId}
generations={Math.min(effAnc, 8)}
/>
</div>
) : (
<div
ref={containerRef}
className="f3 rounded-xl border border-[var(--border)]"
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
/>
)}
<p className="text-sm text-[var(--muted)]">
{mode === "fan"
? "Click an ancestor to recenter the fan."
: "Drag to pan · scroll to zoom · click a person to recenter."}
</p>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More