The person page fetched the entire tree on every open — all persons (to build a
name map + power the relative pickers) and all events (to find partnership
events). On a 2k-person tree that's a ~230KB person list + ~600KB event list per
view. Now it loads only what the page shows:
Frontend:
- The relationship & spouse pickers use the backend's fuzzy pg_trgm search
(debounced, typo-tolerant) instead of substring-filtering a preloaded array —
better search, and no need to preload every person. PersonCombobox gained an
`onSearch` server mode (client `people` mode still works).
- The page drops the all-persons and all-events fetches; it resolves just this
person's relatives' names via GET /persons?ids=..., and reads partnership
events from the per-person events endpoint.
Backend:
- GET /trees/{id}/persons?ids=a,b,c — batch by id (privacy-filtered, names
batched), for relative-name display.
- list_events_for_person (member path) now also returns the person's partnership
events, so the page needn't scan every event in the tree.
Adversarial review (frontend logic + backend/privacy) found no issues. Suite 105
passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
Two changes.
1. Privacy fix (NN#2/NN#3) — the citation and source list endpoints gated only
on can_view_tree, so a non-member on a public/unlisted/site_members tree could
enumerate citations and sources tied to a redacted living person, leaking that
the person exists and has sourced facts (and possibly their name via a source
title). #46 closed this for events/media/names/relationships but not
citations/sources. Now citation_service.list_citations and
source_service.{list_sources,get_source} delegate non-member reads to
public_view_service, mirroring the #46 pattern:
- citations: shown only when the cited fact resolves to FULL-visibility
person(s) — covers the person_id, name_id, event_id (person or both-partner),
and relationship_id (both-partner) target paths.
- sources: shown only when they back at least one visible citation; a withheld
source 404s (don't reveal it exists).
Tests cover all four citation target types + source withholding + member-sees-all.
2. On-demand tree purge — owners can permanently delete a soft-deleted tree now
instead of waiting out the 30-day auto-purge window. POST /trees/{id}/purge
(owner-only): the tree must already be in the trash, and the caller retypes its
name to confirm. Media objects are deleted from storage, then a single
DELETE on trees cascades all tree-owned rows via the tree_id ON DELETE CASCADE;
the audit entry survives (tree_id SET NULL). Frontend adds a "Delete forever"
button to the Recently-deleted list. No migration.
Suite: 102 passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
Provenance had no system-level owner: ownership was only per-tree
(TreeMembership), so a self-hosted instance had no operator account and no
instance-admin surface. This adds one, declared by environment per the project's
twelve-factor rule.
- OWNER_EMAIL (comma-separated): the account(s) named here are instance owners.
Derived at request time — no DB column, no migration, can't drift from the env,
survives DB resets. is_instance_owner()/InstanceOwner dependency in api/deps.py.
- Ownership requires a VERIFIED email (independent of REQUIRE_EMAIL_VERIFICATION).
Registration is open, so without this an attacker could seize the role by
registering the owner address first; verification ties it to inbox control.
- GET /api/v1/admin/instance (owner-only): operational status — version, env,
user/tree counts, configured AI providers. Deliberately exposes no tree data
or PII: instance ownership is an operator role, NOT a privacy-engine bypass.
- /users/me reports is_instance_owner; frontend gains an owner-only /admin page
and a conditional sidebar link (server-enforced, not just client-hidden).
Found-and-fixed by an adversarial security review before merge: the
verified-email land-grab (above) and a frontend null-deref where the admin page
crashed on 401/5xx instead of failing closed.
Docs: .env.example + ARCHITECTURE (notes the not-a-privacy-bypass boundary and
the verified-email requirement). Tests: owner matching, the land-grab guard,
/users/me, and owner-only /admin. Suite 96 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
The operator decides which model providers exist (env / registry — Anthropic,
OpenAI, x.AI, Ollama, several at once). The *tree owner* decides who uses which:
- Members' assistant -> one configured provider (or none)
- Recommender (association/connection finder) -> one configured provider (or none)
- Owner -> may use any configured provider
Backend: two nullable columns on `trees` (ai_member_provider,
ai_recommender_provider) + migration; `configured_llm_providers()` exposes the
registry as {name, model} with no secrets; owner-gated GET/PATCH
/trees/{id}/ai validate names against the configured set. Frontend: owner-only
"AI models" page with a dropdown per role, graceful 403 for non-owners, and a
sidebar link.
Per-model-within-a-provider selection is a follow-up; today each provider maps
to its single configured model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Implements non-negotiable #1: the AI assistant never writes autonomously. Every
assistant/contributor "write" emits a ChangeProposal — a structured diff a human
approves, edits, or rejects. Design: docs/design/change-proposal.md.
Structural guarantee: a proposal's operations reach the DB ONLY via
change_proposal_service.apply(), which requires the actor be an editor and
dispatches each op through the normal editing services (person/name/event/
relationship/source/citation create/update/delete) — so every change passes the
privacy engine and is audited as the approving human. propose() only inserts a
pending row; it performs no domain mutation. Model providers stay read-only, so
no model response can mutate tree data.
- ChangeProposal model + migration (status pending|applied|rejected, origin
assistant|contributor, JSONB operations, reviewer + apply_error).
- Service: propose / list / get / apply (with optional edited ops) / reject /
delete; a dispatcher mapping ops → editing services. v1 applies ops in order,
not cross-op transactional (single-op is atomic; documented).
- API /trees/{id}/proposals + a frontend review page (approve/reject; editor-
gated) and sidebar entry.
Tests: proposal doesn't apply until approved; reject doesn't apply; non-editor
member can see but not apply; multi-op; approve-with-edits; apply-error keeps it
pending. Full suite 87 passed; single alembic head.
Closes#214
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
TreeMembership was enforced on every read/write but had no API or UI to manage
members — trees were effectively single-user, breaking full-CRUD (NN#8).
Backend (/trees/{id}/members): list (members only — the list exposes emails, so
non-members never see it, even on public trees); add an existing user by email
(owner only, 404 if no such account, 409 if already a member); PATCH role;
DELETE. A tree must always keep ≥1 owner (demote/remove of the sole owner → 409).
All changes audited.
Frontend: a Members page (owner gets add-by-email + per-member role select +
remove; others see a read-only list) and a sidebar entry.
Test covers the full lifecycle + every guard. Suite 77 passed.
Closes#145
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
- 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>
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>
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>
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>
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>
Replaces the centered single-column of full-width cards with a proper application layout: a persistent left sidebar (Trees, and per-tree People/Sources/Media, with the tree name and sign-out) and a constrained content column. Marketing landing and auth pages are split out (own header/footer; centered auth with the logo).
Adds a Media gallery (upload + image thumbnails / file tiles, served via the backend content endpoint). Events are no longer free-text: a curated event-type list (+ custom) and a structured date (qualifier + day/month/year) that composes a proper genealogical date. Regenerated the OpenAPI client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
New /trees/[id]/sources page (list + create sources). Person-detail page now loads tree sources + citations and shows a '✓ N sourced' badge with an inline cite picker (source + page) on each event and on the person. Tree view links to Sources. Regenerated the OpenAPI client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
New /trees/[id]/persons/[personId] view: life-events timeline with add/remove, and relationships grouped into parents/children/partners/siblings with an add form (kind + person picker + qualifier). People in the tree list now link here. Regenerated the OpenAPI client for the new endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Next.js (App Router) + React 19 + TypeScript + Tailwind v4, with shadcn-style UI primitives (Button, Input, Card, Label via cva/tailwind-merge). A typed API client is generated from the backend OpenAPI spec with openapi-typescript + openapi-fetch (npm run gen:api); the committed openapi.json/schema.d.ts are the snapshot.
Views: landing, login, register, tree list + create, and tree detail with person list + create. Auth rides the same-origin HttpOnly session cookie the backend sets (Caddy proxies /api/*), so no token handling in JS. Built as a standalone container. Mobile-first; next build is clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>