20 Commits

Author SHA1 Message Date
justin 7043532c3b Merge pull request 'Cleanup tool: mark deceased by a child's birth year' (#254) from cleanup-deceased-by-child into main
build-backend / build (push) Successful in 29s
build-frontend / build (push) Successful in 1m29s
2026-06-11 11:08:52 -04:00
justin 1340d1957f Cleanup tool: "mark deceased by a child's birth year" rule
Adds a preview/apply rule to the Cleanup tool for parents who have NO birth date
of their own (so the existing born-on-or-before rule can't reach them) but who
have a child born long ago — they're necessarily deceased. This is the gap that
left ~56 parents in the Paul tree as "unknown".

- cleanup_service.preview_deceased_by_child(year): parents of any child born
  on/before the cutoff, excluding already-deceased; returns child_birth_year.
- GET /trees/{id}/cleanup/deceased-by-child?born_on_or_before=1900. Apply reuses
  the existing POST .../cleanup/deceased (same audited mark-deceased path).
- Frontend: a new card in the Cleanup tool (year input → preview → select →
  apply), preview-first like the rest of the tool.

Test covers preview (finds the no-birthdate parent of a pre-cutoff child,
excludes modern-child parents), child_birth_year, apply, and re-preview drop.
Suite 106 passing.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 11:08:50 -04:00
justin e24a7cfcc9 Merge pull request 'Tree cards: living/unset-sex people render gray, not blue' (#253) from living-and-unset-cards-gray into main
build-frontend / build (push) Successful in 1m28s
2026-06-11 10:37:27 -04:00
justin 07944e329e Tree cards: render unset-sex / redacted "Living person" in gray, not blue
The chart mapped gender as `=== "female" ? "F" : "M"`, so anything non-female —
including null — became "M" (blue). On the public site, redacted living people
(whose gender the privacy engine nulls) all showed blue regardless of real sex,
and anywhere a person's sex was simply unset they also showed blue (misleading).

Map male→"M", female→"F", and everything else→null, which family-chart renders
as `card-genderless`. So living/redacted people render gray (and never imply a
sex), and unset-sex people render gray instead of defaulting to male/blue.
Applied to both the member tree (tree/page.tsx) and the public chart
(public-tree-chart.tsx), which share chart.css. Also bumped the genderless color
from the library's washed-out `lightgray` to a warm mid-gray that matches the
muted male/female tones and the brand palette.

Privacy note: `_redact` already nulls gender, so this is purely the client color
mapping — no sex leak, just a correct neutral rendering.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 10:37:25 -04:00
justin a33a88e558 Merge pull request 'docs: note the spouse-layout fix is upstreamed' (#252) from docs-upstream-spouse-fix into main 2026-06-11 09:33:21 -04:00
justin fe8349819f docs: note the spouse-layout fix is upstreamed (donatso/family-chart#105)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 09:33:19 -04:00
justin e745fb5d4d Merge pull request 'Move cardToMiddle fix into the family-chart patch (+ document patches)' (#251) from family-chart-patch-cardtomiddle into main
build-frontend / build (push) Successful in 1m27s
2026-06-11 09:21:32 -04:00
justin e0573e6be2 Move cardToMiddle vertical-centering fix into the family-chart patch
Fold the fly-to vertical-centering fix into our patch-package patch (alongside
the existing spouse-layout fix) instead of compensating in app code, and revert
the in-app workaround so the two don't double-correct.

- patches/family-chart+0.9.0.patch: cardToMiddle now scales datum.y by the zoom
  k in both dist builds (.js + .esm.js), matching datum.x. Verified the patch
  applies cleanly (patch-package --error-on-fail).
- tree/page.tsx: the cardToMiddle caller passes raw y again (the patched library
  does the scaling now); pre-scaling here too would double-correct. Behavior is
  identical to the previous in-app fix — both center the node exactly.
- CLAUDE.md: documents the two family-chart patches, how to regenerate them, and
  that both should be upstreamed. The cardToMiddle fix is submitted upstream
  (donatso/family-chart#103, issue #102); the spouse-layout fix is a TODO.

The frontend Dockerfile already COPYs patches/ before npm ci, so the fix is in
the production build.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 09:21:30 -04:00
justin 3731d77d4b Merge pull request 'Fix fly-to vertical centering at non-1 zoom levels' (#250) from fix-fly-to-vertical-centering into main
build-frontend / build (push) Successful in 1m28s
2026-06-11 08:58:38 -04:00
justin bf1576252b Fix fly-to vertical centering at non-1 zoom levels
Clicking ×N sometimes flew to a blank area far below the tree. Cause:
family-chart's cardToMiddle scales datum.x by the zoom factor k but not datum.y
(`y = height/2 - datum.y`, missing the ·k), so vertical centering is only
correct at k=1 and drifts by datum.y·(k−1) at any other zoom — worse the deeper
the person sits. That's why it worked only when the view happened to be near 1:1.

Compensate by pre-multiplying the y we pass to cardToMiddle by the current
scale, cancelling the library's missing ·k. x was already correct.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:58:36 -04:00
justin 0ed6ba4505 Merge pull request 'Tree: clicking ×N flies to the person's other copy' (#249) from tree-fly-to-duplicate into main
build-frontend / build (push) Successful in 1m31s
2026-06-11 08:47:59 -04:00
justin ed263cf9a7 Tree: clicking ×N flies to the person's other copy (not just flashes)
On a large tree the duplicate's other copy is usually off-screen, so flashing
in place wasn't enough. Clicking the ×N badge now pans/zooms the view to center
the other copy and flashes it on arrival; clicking again cycles through the
remaining copies (for a person drawn 3+ times).

Uses family-chart's exported handlers: cardToMiddle centers a datum (read from
the target card_cont's bound x/y, falling back to its transform attr), keeping
the current zoom level via getCurrentZoom. Verified against the lib: the svg's
parent (f3Canvas) holds the zoom object, and cards are positioned by datum x/y —
same coordinate space cardToMiddle expects. Falls back to an in-place flash if
the zoom object isn't ready. Frontend only; supersedes the flash-only behavior.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:47:44 -04:00
justin f7666ad30b Merge pull request 'Tree: Legend by the pan/zoom hint + clickable ×N duplicate badges' (#248) from tree-legend-and-duplicate-flash into main
build-frontend / build (push) Successful in 1m28s
2026-06-11 08:32:53 -04:00
justin 690a6da659 Tree: a Legend by the pan/zoom hint, and clickable ×N duplicate badges
Two small tree-view aids prompted by "why do some people show ×2".

- Legend: a hover/focus "Legend" link next to the "drag to pan…" hint, explaining
  the ×N badge (a person drawn N times in the view because they connect through
  more than one line — a shared ancestor or an intermarriage), the gender card
  colors, and the pan/zoom/recenter controls.
- The ×N badge is now clearly clickable (cursor + hover state); clicking it
  flashes every copy of that person in the current view (a bronze outline pulse),
  so you can spot where else they appear. Implemented by delegating on the chart
  container and matching the d3-bound person id across cards; capture-phase +
  stopPropagation so a badge click flashes instead of recentering.

Frontend only. Honest follow-up: flashing finds copies that are on-screen; a true
"fly to an off-screen copy" needs d3-zoom transform work (the chart pans by
transform, not scroll) — a later enhancement.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:32:35 -04:00
justin e7115023e1 Merge pull request 'Person page: server-side search; stop loading the whole tree' (#247) from person-page-server-search into main
build-backend / build (push) Successful in 38s
build-frontend / build (push) Successful in 1m30s
2026-06-11 08:29:32 -04:00
justin 58400ffdf7 Person page: server-side search; stop loading the whole tree
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>
2026-06-11 08:29:13 -04:00
justin 629bfa1367 Merge pull request 'Fix list_persons N+1 (the ~4s person-page load)' (#246) from fix-person-list-n-plus-one into main
build-backend / build (push) Successful in 37s
2026-06-11 08:00:47 -04:00
justin 1562febdcf Fix list_persons N+1 (the ~4s person-page load)
Opening any person page on a large tree took 4-5s on an idle server. Root cause:
list_persons looped over every person calling privacy.person_visibility (which
issues TWO get_membership_role queries per call) AND _attach_primary_name (one
name query per person). On the reporter's 2,324-person tree that's ~7,000
serialized DB round-trips per page load — the person page fetches the full
person list to build its name-lookup map.

Fix:
- Resolve the viewer's membership role ONCE. Members see the whole tree (full),
  so skip the per-person privacy engine entirely.
- Add _attach_primary_names: one batched names query (person_id IN (...),
  ordered the same as the single-person query so it picks the same name) instead
  of one per person.
- Apply the same batching to the non-member path, search_persons, the deleted-
  persons list, and public_view_service.list_public_persons.

Member-path list_persons goes from ~3·N queries to ~3 total. Other tree-wide
list endpoints (events/relationships/media/citations) were already flat selects.

Adds a regression test that asserts list_persons issues a constant number of
queries (not proportional to person count). Suite: 103 passing.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:00:30 -04:00
justin 265f5f4e7a Merge pull request 'Close citation/source living-person leak; add on-demand tree purge' (#245) from citation-redaction-and-tree-purge into main
build-backend / build (push) Successful in 32s
build-frontend / build (push) Successful in 1m26s
2026-06-10 22:39:15 -04:00
justin a6179037c2 Close citation/source living-person leak; add on-demand tree purge
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>
2026-06-10 22:38:59 -04:00
29 changed files with 1458 additions and 97 deletions
+17
View File
@@ -77,6 +77,23 @@ Don't get ahead of the phases. GEDCOM and the assistant's propose-diff foundatio
- **Privacy/assistant/hint code gets extra care** — these are the areas where bugs do real harm. Prefer a design note before a large change.
- **No secrets in the repo.** Config via env; provide `.env.example` with placeholders.
## Patched dependencies (family-chart)
The tree view uses **family-chart** (d3-based). Two adjustments live in the repo:
- **CSS is vendored** at `frontend/app/trees/[id]/tree/chart.css` — the package blocks its CSS subpath export, so we copy it in.
- **The library is patched** via `patch-package` (`frontend/patches/family-chart+0.9.0.patch`, applied by the `postinstall` hook; the backend/frontend Dockerfiles `COPY patches` before install). Both hunks touch `dist/family-chart.js` **and** `dist/family-chart.esm.js` (the app loads the `esm` build). Current fixes:
1. **Spouse-centering layout** (`setupSpouses` / `sortChildrenWithSpouses`) — center a person between two spouses with children under the correct pair.
2. **`cardToMiddle` vertical centering** — the lib scaled `datum.x` by the zoom factor `k` but not `datum.y`, so "fly to a node" drifted vertically at any zoom ≠ 1; we add the missing `* k`.
To change a patch: edit the file(s) under `node_modules/family-chart/dist/`, then `cd frontend && npx patch-package family-chart` to regenerate, and verify with `npx patch-package --error-on-fail`.
**Upstreamed.** Both are general library bugfixes, not app-specific, and are submitted upstream:
- `cardToMiddle` vertical centering — **donatso/family-chart#103** (issue **#102**).
- Multi-spouse centered layout — **donatso/family-chart#105** (issue **#104**).
If either is merged + released, bump `family-chart`, drop the corresponding patch hunk, **and** remove any in-app compensation (e.g. the `cardToMiddle` caller in `tree/page.tsx` passes raw `y` precisely because the patch fixes it — pre-scaling there too would double-correct). Until then, keep the patch.
## License & contribution terms
Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution.
+19
View File
@@ -6,6 +6,7 @@ from app.api.deps import CurrentUser, SessionDep
from app.schemas.cleanup import (
CleanupResult,
DeceasedApply,
DeceasedByChildCandidate,
DeceasedCandidate,
GenderApply,
GenderProposal,
@@ -31,6 +32,24 @@ async def preview_deceased(
return [DeceasedCandidate(**r) for r in rows]
@router.get(
"/{tree_id}/cleanup/deceased-by-child", response_model=list[DeceasedByChildCandidate]
)
async def preview_deceased_by_child(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
born_on_or_before: int = 1900,
) -> list[DeceasedByChildCandidate]:
"""People with a child born on/before the cutoff — necessarily deceased even
when their own birth date is missing. Apply via POST .../cleanup/deceased."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.preview_deceased_by_child(
session, actor=current, tree=tree, year=born_on_or_before
)
return [DeceasedByChildCandidate(**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
+11 -2
View File
@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, status
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
@@ -41,9 +41,18 @@ async def list_persons(
current: CurrentUser,
deleted: bool = False,
q: str | None = None,
ids: str | None = None,
) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
if q:
if ids is not None:
try:
id_list = [uuid.UUID(x) for x in ids.split(",") if x.strip()]
except ValueError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "invalid ids") from exc
persons = await person_service.list_persons_by_ids(
session, viewer_id=current.id, tree=tree, ids=id_list
)
elif q:
persons = await person_service.search_persons(
session, viewer_id=current.id, tree=tree, query=q
)
+17 -2
View File
@@ -2,8 +2,8 @@ import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.tree import TreeCreate, TreePurge, TreeRead, TreeUpdate
from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"])
@@ -57,3 +57,18 @@ async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentU
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)
@router.post("/{tree_id}/purge", status_code=status.HTTP_204_NO_CONTENT)
async def purge_tree(
tree_id: uuid.UUID,
data: TreePurge,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> None:
"""Permanently delete a soft-deleted tree and all its data — irreversible.
Owner-only; the tree must be in the trash and `confirm_name` must match."""
await tree_service.purge_tree(
session, store, actor=current, tree_id=tree_id, confirm_name=data.confirm_name
)
+6
View File
@@ -9,6 +9,12 @@ class DeceasedCandidate(BaseModel):
birth_year: int
class DeceasedByChildCandidate(BaseModel):
person_id: uuid.UUID
name: str
child_birth_year: int
class DeceasedApply(BaseModel):
person_ids: list[uuid.UUID]
+5
View File
@@ -19,6 +19,11 @@ class TreeUpdate(BaseModel):
home_person_id: uuid.UUID | None = None
class TreePurge(BaseModel):
# Retype the tree's name to confirm a permanent, irreversible delete.
confirm_name: str
class TreeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
+9
View File
@@ -105,6 +105,15 @@ async def list_citations(
indicators in a single round-trip."""
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 only citations whose cited fact resolves to a full-
# visibility person — a citation on a redacted living person's fact would
# otherwise leak that the person has that sourced fact.
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_citations(
session, viewer_id=viewer_id, tree=tree
)
stmt = (
select(Citation)
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
+45
View File
@@ -133,6 +133,51 @@ async def apply_deceased(
return len(persons)
# ---- 1b. Mark deceased by a CHILD's birth year -------------------------------------
# For parents whose own birth date is missing (so the birth-year rule can't reach
# them) but who have a child born long ago — they're necessarily deceased. Applies
# through the same apply_deceased() path.
async def preview_deceased_by_child(
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)
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.parent_child,
)
)
).scalars().all()
# parent id -> earliest child birth year, among children born on/before `year`.
earliest_child: dict[uuid.UUID, int] = {}
for r in rels:
cy = years.get(r.person_to_id) # the child's birth year
if cy is None or cy > year:
continue
if r.person_from_id not in earliest_child or cy < earliest_child[r.person_from_id]:
earliest_child[r.person_from_id] = cy
persons = {p.id: p for p in await _persons(session, tree.id)}
out: list[dict] = []
for parent_id, cy in earliest_child.items():
p = persons.get(parent_id)
if p is None or p.is_living is False: # gone or already deceased
continue
out.append(
{
"person_id": str(parent_id),
"name": _display(names.get(parent_id)),
"child_birth_year": cy,
}
)
out.sort(key=lambda r: r["child_birth_year"])
return out
# ---- 2. Re-derive gender from a source GEDCOM (matches by name) ----------------------
async def preview_gender(
+21 -2
View File
@@ -4,9 +4,10 @@ engine. Every event has exactly one subject — a Person or a partnership."""
import uuid
from datetime import date
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import RelationshipType
from app.models.event import Event
from app.models.person import Person
from app.models.place import Place
@@ -124,12 +125,30 @@ async def list_events_for_person(
return await public_view_service.list_public_person_events(
session, viewer_id=viewer_id, tree=tree, person_id=person_id
)
# Member view: this person's own events PLUS their partnership events (which
# live on the relationship and show on both partners). Returning both here
# means the person page doesn't have to load every event in the tree.
partner_rel_ids = (
select(Relationship.id)
.where(
Relationship.tree_id == tree.id,
Relationship.type == RelationshipType.partnership,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person_id,
Relationship.person_to_id == person_id,
),
)
)
stmt = (
select(Event)
.where(
Event.tree_id == tree.id,
Event.person_id == person_id,
Event.deleted_at.is_(None),
or_(
Event.person_id == person_id,
Event.relationship_id.in_(partner_rel_ids),
),
)
.order_by(Event.date_start.nulls_last(), Event.created_at)
)
+86 -5
View File
@@ -45,6 +45,29 @@ async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
person.primary_name = _format_name(name) if name is not None else None
async def _attach_primary_names(session: AsyncSession, persons: list[Person]) -> None:
"""Batch version of ``_attach_primary_name`` — ONE query for the whole list
instead of one per person (the difference between 1 and N queries when
rendering a 2k-person tree). The global order (is_primary desc, sort_order)
matches the single-person query, so the first row seen per person is the same
name ``_attach_primary_name`` would pick."""
if not persons:
return
rows = (
await session.execute(
select(Name)
.where(Name.person_id.in_([p.id for p in persons]), Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().all()
best: dict[uuid.UUID, Name] = {}
for n in rows:
best.setdefault(n.person_id, n)
for p in persons:
n = best.get(p.id)
p.primary_name = _format_name(n) if n is not None else None
async def create_person(
session: AsyncSession,
*,
@@ -336,15 +359,18 @@ async def list_deleted_persons(
.order_by(Person.deleted_at.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
for person in persons:
await _attach_primary_name(session, person)
await _attach_primary_names(session, persons)
return persons
async def list_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):
# Resolve the viewer's role ONCE. Members see the whole tree (full), so we
# skip the per-person privacy engine entirely and batch the name fetch — the
# difference between ~3 queries and ~3·N queries on a 2k-person tree.
role = await privacy.get_membership_role(session, viewer_id, tree.id)
if role is None and not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
@@ -354,7 +380,15 @@ async def list_persons(
)
persons = list((await session.execute(stmt)).scalars().all())
if role is not None:
await _attach_primary_names(session, persons)
return persons
# Non-member on a viewable (public/unlisted/site_members) tree: redact per
# person. Names are batched for the non-redacted ones; redacted ones already
# have their display name overwritten by _redact.
visible: list[Person] = []
full: list[Person] = []
for person in persons:
vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person
@@ -364,8 +398,50 @@ async def list_persons(
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
full.append(person)
visible.append(person)
await _attach_primary_names(session, full)
return visible
async def list_persons_by_ids(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, ids: list[uuid.UUID]
) -> list[Person]:
"""Just the named persons (privacy-filtered, names batched). Lets a page show
the names of someone's relatives without loading the whole tree."""
role = await privacy.get_membership_role(session, viewer_id, tree.id)
if role is None and not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
if not ids:
return []
persons = list(
(
await session.execute(
select(Person).where(
Person.id.in_(ids),
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
)
)
).scalars().all()
)
if role is not None:
await _attach_primary_names(session, persons)
return persons
visible: list[Person] = []
full: 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:
full.append(person)
visible.append(person)
await _attach_primary_names(session, full)
return visible
@@ -406,7 +482,11 @@ async def search_persons(
.order_by(sub.c.score.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
if await privacy.get_membership_role(session, viewer_id, tree.id) is not None:
await _attach_primary_names(session, persons)
return persons
out: list[Person] = []
full: list[Person] = []
for person in persons:
vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person
@@ -416,6 +496,7 @@ async def search_persons(
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
full.append(person)
out.append(person)
await _attach_primary_names(session, full)
return out
+100 -2
View File
@@ -12,6 +12,8 @@ person's real name, dates, alternate names, or media. The rules:
living partner's timeline otherwise).
- names : only for FULL-visibility persons.
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md).
- citations : only when the cited fact resolves to FULL person(s).
- sources : only when they back at least one visible citation.
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.
@@ -27,10 +29,15 @@ 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.source import Citation, Source
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.person_service import (
_attach_primary_name,
_attach_primary_names,
_redact,
)
from app.services.privacy import Visibility
@@ -75,6 +82,7 @@ async def list_public_persons(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> list[Person]:
out: list[Person] = []
full: 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:
@@ -82,8 +90,9 @@ async def list_public_persons(
if vis == Visibility.redacted:
_redact(p)
else:
await _attach_primary_name(session, p)
full.append(p)
out.append(p)
await _attach_primary_names(session, full) # one query, not one per person
return out
@@ -296,6 +305,95 @@ async def can_view_media(
return vis == Visibility.full
async def _full_person_ids(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> set[uuid.UUID]:
persons = await _persons(session, tree)
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
return {pid for pid, v in vis.items() if v == Visibility.full}
async def list_public_citations(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> list[Citation]:
"""Only citations whose cited fact resolves to FULL-visibility person(s). A
citation on a redacted/hidden person's fact (or a partnership where either
partner isn't full) is dropped — its existence plus page/detail would leak
that the person has that sourced fact. Mirrors the events/names rule (FULL
only)."""
full = await _full_person_ids(session, viewer_id=viewer_id, tree=tree)
async def _by_id(model):
rows = (
await session.execute(
select(model).where(model.tree_id == tree.id, model.deleted_at.is_(None))
)
).scalars().all()
return {r.id: r for r in rows}
names = await _by_id(Name)
rels = await _by_id(Relationship)
events = await _by_id(Event)
def target_is_full(c: Citation) -> bool:
if c.person_id is not None:
return c.person_id in full
if c.name_id is not None:
n = names.get(c.name_id)
return n is not None and n.person_id in full
if c.event_id is not None:
e = events.get(c.event_id)
if e is None:
return False
if e.person_id is not None:
return e.person_id in full
if e.relationship_id is not None:
r = rels.get(e.relationship_id)
return r is not None and r.person_from_id in full and r.person_to_id in full
return False
if c.relationship_id is not None:
r = rels.get(c.relationship_id)
return r is not None and r.person_from_id in full and r.person_to_id in full
return False
citations = (
await session.execute(
select(Citation)
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
.order_by(Citation.created_at)
)
).scalars().all()
return [c for c in citations if target_is_full(c)]
async def list_public_sources(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> list[Source]:
"""Only sources backing at least one visible citation. A source used solely
for a redacted/hidden person's facts is withheld — its title or notes could
name that living person."""
visible = await list_public_citations(session, viewer_id=viewer_id, tree=tree)
cited = {c.source_id for c in visible}
sources = (
await session.execute(
select(Source)
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
.order_by(Source.title)
)
).scalars().all()
return [s for s in sources if s.id in cited]
async def get_public_source(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, source_id: uuid.UUID
) -> Source:
for s in await list_public_sources(session, viewer_id=viewer_id, tree=tree):
if s.id == source_id:
return s
# 404 (not 403): don't reveal that a withheld source exists.
raise NotFound("source not found")
async def list_public_trees(
session: AsyncSession,
*,
+14
View File
@@ -61,6 +61,14 @@ async def create_source(
async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Source]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
# Non-members see only sources backing a visible citation (see citation
# redaction) — a source used solely for a redacted person could name them.
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_sources(
session, viewer_id=viewer_id, tree=tree
)
stmt = (
select(Source)
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
@@ -74,6 +82,12 @@ async def get_source(
) -> Source:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=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.get_public_source(
session, viewer_id=viewer_id, tree=tree, source_id=source_id
)
source = (
await session.execute(
select(Source).where(
+48 -2
View File
@@ -5,16 +5,18 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.objectstore.base import ObjectStore
from app.models.enums import MembershipRole, TreeVisibility
from app.models.media import Media
from app.models.tree import Tree, TreeMembership
from app.models.user import User
from app.repositories.base import BaseRepository
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
from app.services.exceptions import Conflict, Forbidden, NotFound
async def create_tree(
@@ -128,6 +130,50 @@ async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID
return tree
async def purge_tree(
session: AsyncSession,
store: ObjectStore,
*,
actor: User,
tree_id: uuid.UUID,
confirm_name: str,
) -> None:
"""Permanently delete a soft-deleted tree and ALL its data — irreversible.
Owner-only. The tree must already be in the trash (soft-deleted) and the
caller must retype its name. Tree-owned rows are removed by the `tree_id`
ON DELETE CASCADE; we delete the media objects from storage first (the DB
cascade drops the rows but not the bytes). Audit entries survive with their
`tree_id` nulled (ON DELETE SET NULL), so the purge stays in the log."""
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is None:
raise Conflict("delete the tree first, then purge it from the trash")
if confirm_name.strip() != (tree.name or "").strip():
raise Forbidden("tree name confirmation does not match")
keys = list(
(
await session.execute(select(Media.storage_key).where(Media.tree_id == tree.id))
).scalars().all()
)
for key in keys:
try:
await store.delete_object(key=key)
except Exception: # noqa: BLE001 — best-effort; a missing object must not block the purge
pass
record_audit(
session,
action="purge",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
before={"name": tree.name},
)
await session.execute(delete(Tree).where(Tree.id == tree.id))
await session.commit()
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
stmt = (
select(Tree)
@@ -106,6 +106,142 @@ async def test_authed_nonmember_does_not_see_living_pii(client):
).status_code == 200
async def _setup_sources(client):
owner = auth(await register(client, "anmcs-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "PubCS", "visibility": "public"}, headers=owner
)
).json()["id"]
old = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Oldcs", "surname": "Gonecs", "is_living": False},
headers=owner,
)
).json()["id"]
young = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Youngcs", "surname": "Csleaksurname", "is_living": True},
headers=owner,
)
).json()["id"]
for pid, year in ((old, "1851"), (young, "2004")):
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": pid, "date_value": year},
headers=owner,
)
s_old = (
await client.post(
f"/api/v1/trees/{tid}/sources", json={"title": "Oldsource record"}, headers=owner
)
).json()["id"]
s_young = (
await client.post(
f"/api/v1/trees/{tid}/sources",
json={"title": "Youngsource Csleaktitle"}, # title names the living person
headers=owner,
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/citations",
json={"source_id": s_old, "person_id": old, "page": "p.1"},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/citations",
json={"source_id": s_young, "person_id": young, "page": "p.2"},
headers=owner,
)
return owner, tid, old, young, s_old, s_young
async def test_authed_nonmember_citation_source_redaction(client):
"""A non-member must not see citations on a redacted living person's facts,
nor sources used only for them."""
owner, tid, old, young, s_old, s_young = await _setup_sources(client)
stranger = auth(await register(client, "anmcs-stranger@ex.com"))
cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json()
cited = {c.get("person_id") for c in cites}
assert old in cited
assert young not in cited # living person's citation dropped
srcs = (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger))
src_ids = {s["id"] for s in srcs.json()}
assert s_old in src_ids
assert s_young not in src_ids # source used only for the living person withheld
assert "Csleaktitle" not in srcs.text # its title (which names them) must not leak
# The withheld source 404s — don't reveal it exists; the visible one is fine.
assert (
await client.get(f"/api/v1/trees/{tid}/sources/{s_young}", headers=stranger)
).status_code == 404
assert (
await client.get(f"/api/v1/trees/{tid}/sources/{s_old}", headers=stranger)
).status_code == 200
# Members still see everything.
mc = {c.get("person_id") for c in (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json()}
assert {old, young} <= mc
ms = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=owner)).json()}
assert {s_old, s_young} <= ms
async def test_citation_redaction_via_indirect_targets(client):
"""Citations targeting a living person *indirectly* (via their event or name,
not person_id) must also be dropped for non-members."""
owner = auth(await register(client, "anmind-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "PubInd", "visibility": "public"}, headers=owner
)
).json()["id"]
young = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Youngind", "surname": "Indsurname", "is_living": True},
headers=owner,
)
).json()["id"]
ev = (
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": young, "date_value": "2005"},
headers=owner,
)
).json()["id"]
nm = (
await client.post(
f"/api/v1/trees/{tid}/persons/{young}/names",
json={"name_type": "alias", "given": "Indalias"},
headers=owner,
)
).json()["id"]
s_ev = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "EvSrc"}, headers=owner)).json()["id"]
s_nm = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "NmSrc"}, headers=owner)).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/citations", json={"source_id": s_ev, "event_id": ev}, headers=owner
)
await client.post(
f"/api/v1/trees/{tid}/citations", json={"source_id": s_nm, "name_id": nm}, headers=owner
)
stranger = auth(await register(client, "anmind-stranger@ex.com"))
cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json()
# Neither the event-citation nor the name-citation may surface.
assert not any(c.get("event_id") == ev for c in cites)
assert not any(c.get("name_id") == nm for c in cites)
src_ids = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger)).json()}
assert s_ev not in src_ids and s_nm not in src_ids
# Owner (member) sees both citations and both sources.
mc = (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json()
assert any(c.get("event_id") == ev for c in mc) and any(c.get("name_id") == nm for c in mc)
async def test_member_still_sees_everything(client):
owner, tid, old, young, om, ym = await _setup(client)
+47
View File
@@ -51,6 +51,53 @@ async def test_deceased_preview_and_apply(client):
assert old not in [r["person_id"] for r in prev2]
async def test_deceased_by_child_preview_and_apply(client):
h, tid = await _tree(client, "cl-decchild@example.com")
# Parent with NO birth date (the gap the birth-year rule can't reach).
parent = await _person(client, h, tid, "Gesche", "Frerking")
child = await _person(client, h, tid, "Kindt", "Frerking")
await _birth(client, h, tid, child, 1880) # child born before the cutoff
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": parent, "person_to_id": child},
headers=h,
)
# A parent of a modern child must NOT be flagged.
p_modern = await _person(client, h, tid, "Modern", "Parent")
c_modern = await _person(client, h, tid, "Kid", "Parent")
await _birth(client, h, tid, c_modern, 1990)
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": p_modern, "person_to_id": c_modern},
headers=h,
)
prev = (
await client.get(
f"/api/v1/trees/{tid}/cleanup/deceased-by-child?born_on_or_before=1900", headers=h
)
).json()
ids = [r["person_id"] for r in prev]
assert parent in ids and p_modern not in ids
assert next(r for r in prev if r["person_id"] == parent)["child_birth_year"] == 1880
# Apply through the shared deceased endpoint.
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/deceased", json={"person_ids": [parent]}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 1
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{parent}", headers=h)
).json()["is_living"] is False
# Re-preview drops the now-deceased parent.
prev2 = (
await client.get(
f"/api/v1/trees/{tid}/cleanup/deceased-by-child?born_on_or_before=1900", headers=h
)
).json()
assert parent 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 = (
@@ -0,0 +1,39 @@
"""Regression guard: list_persons must batch — a constant number of queries,
not one (or three) per person. A 2k-person tree took ~4s before this was fixed."""
import sqlalchemy as sa
from tests.conftest import auth, register
async def test_list_persons_does_not_n_plus_one(client, engine):
owner = auth(await register(client, "perf-owner@ex.com"))
tid = (await client.post("/api/v1/trees", json={"name": "Perf"}, headers=owner)).json()["id"]
n = 25
for i in range(n):
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": f"P{i}", "surname": "X"},
headers=owner,
)
selects = 0
def _count(conn, cursor, statement, params, context, executemany):
nonlocal selects
if statement.lstrip().upper().startswith("SELECT"):
selects += 1
sa.event.listen(engine.sync_engine, "before_cursor_execute", _count)
try:
resp = await client.get(f"/api/v1/trees/{tid}/persons", headers=owner)
finally:
sa.event.remove(engine.sync_engine, "before_cursor_execute", _count)
assert resp.status_code == 200
body = resp.json()
assert len(body) == n
assert all(p["primary_name"] for p in body) # names still resolve correctly
# Batched: a small constant (auth, role, persons, one names query, …) — NOT
# proportional to n. The old per-person path was ~3·n SELECTs.
assert 0 < selects < n, f"expected a constant query count, got {selects} for {n} people"
+60
View File
@@ -0,0 +1,60 @@
"""Backing the trimmed person-page fetch: batch persons by id (for relative-name
display) and partnership events on the per-person events endpoint (so the page
doesn't load every event in the tree)."""
from tests.conftest import auth, register
async def _tree(client, h):
return (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
async def test_list_persons_by_ids(client):
h = auth(await register(client, "ids@ex.com"))
tid = await _tree(client, h)
a = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Aaa"}, headers=h)).json()["id"]
b = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Bbb"}, headers=h)).json()["id"]
c = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Ccc"}, headers=h)).json()["id"]
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": f"{a},{c}"}, headers=h)
assert r.status_code == 200
assert {p["id"] for p in r.json()} == {a, c} # only the requested, not b
assert all(p["primary_name"] for p in r.json()) # names resolved
assert (
await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": "nope"}, headers=h)
).status_code == 422
assert (
await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": ""}, headers=h)
).json() == []
async def test_person_events_include_partnership(client):
h = auth(await register(client, "pev@ex.com"))
tid = await _tree(client, h)
p1 = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "P1"}, headers=h)).json()["id"]
p2 = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "P2"}, headers=h)).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": p1, "date_value": "1900"},
headers=h,
)
rel = (
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": p1, "person_to_id": p2},
headers=h,
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "marriage", "relationship_id": rel, "date_value": "1925"},
headers=h,
)
# P1's events: own birth + the partnership marriage, in one call.
e1 = {e["event_type"] for e in (await client.get(f"/api/v1/trees/{tid}/persons/{p1}/events", headers=h)).json()}
assert {"birth", "marriage"} <= e1
# The marriage shows on BOTH partners' pages.
e2 = {e["event_type"] for e in (await client.get(f"/api/v1/trees/{tid}/persons/{p2}/events", headers=h)).json()}
assert "marriage" in e2
+78
View File
@@ -0,0 +1,78 @@
"""On-demand purge of a soft-deleted tree: permanent, owner-only, name-confirmed,
and cascades to all tree data."""
import uuid
from sqlalchemy import func, select
from app.models.person import Person
from app.models.tree import Tree
from tests.conftest import auth, register
async def _tree_with_person(client, owner):
tid = (await client.post("/api/v1/trees", json={"name": "Purge Me"}, headers=owner)).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Doomed", "surname": "Soul"}, headers=owner
)
return tid
async def test_purge_requires_soft_delete_first(client):
owner = auth(await register(client, "purge-a@ex.com"))
tid = await _tree_with_person(client, owner)
# A live tree can't be purged — it must be trashed first.
r = await client.post(
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner
)
assert r.status_code == 409
async def test_purge_name_must_match(client):
owner = auth(await register(client, "purge-b@ex.com"))
tid = await _tree_with_person(client, owner)
await client.delete(f"/api/v1/trees/{tid}", headers=owner) # soft-delete
r = await client.post(
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "WRONG"}, headers=owner
)
assert r.status_code == 403
# Still in the trash — nothing destroyed.
deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json()
assert any(t["id"] == tid for t in deleted)
async def test_purge_owner_only(client):
owner = auth(await register(client, "purge-c@ex.com"))
other = auth(await register(client, "purge-c2@ex.com"))
tid = await _tree_with_person(client, owner)
await client.delete(f"/api/v1/trees/{tid}", headers=owner)
r = await client.post(
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=other
)
assert r.status_code in (403, 404)
async def test_purge_removes_tree_and_cascades(client, db_session):
owner = auth(await register(client, "purge-d@ex.com"))
tid = await _tree_with_person(client, owner)
await client.delete(f"/api/v1/trees/{tid}", headers=owner)
r = await client.post(
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner
)
assert r.status_code == 204
# Gone from the trash...
deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json()
assert not any(t["id"] == tid for t in deleted)
# ...and cascaded: no tree row, no person rows.
tuuid = uuid.UUID(tid)
assert (
await db_session.execute(select(func.count()).select_from(Tree).where(Tree.id == tuuid))
).scalar() == 0
assert (
await db_session.execute(
select(func.count()).select_from(Person).where(Person.tree_id == tuuid)
)
).scalar() == 0
+5 -6
View File
@@ -44,10 +44,9 @@ These two doc edits are themselves trivial quick wins (see §3).
- **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).** Most of the original redaction defects shipped this cycle (#46); two items remain — one a narrowed PII gap, one a config switch:
**Security-priority correctness fixes (do these first, regardless of phase).** The redaction defects all shipped — child resources (#46) and now citations/sources too — leaving one config switch:
1. **Citation/source redaction gap (§2.10)**`list_media`/`get_media`/`media_content`, plus the event/name/relationship endpoints, now apply `person_visibility` for non-members (#46), closing the media leak. The `citation`/`source` list endpoints still gate only on `can_view_tree`, so a non-member on a public/unlisted tree can still enumerate citations/sources tied to redacted living people — the remaining living-person leak.
2. **Self-registration approval-mode switch (§2.10)** — the read-side enforcement now exists: `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (#53). The remaining gap is the env switch to choose open vs admin-approval vs closed self-registration.
1. **Self-registration approval-mode switch (§2.10)** — the read-side enforcement now exists: `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (#53). The remaining gap is the env switch to choose open vs admin-approval vs closed self-registration. *(The citation/source living-person leak is now closed — citation/source list endpoints apply `person_visibility` for non-members via `public_view_service`.)*
**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) finish the back-half spine — the **connector framework** plus wiring the now-landed **ChangeProposal/ModelProvider** into the assistant — that unlocks the entire back half of the roadmap.
@@ -250,7 +249,7 @@ The architecture is correct (single engine, tenant mixin, audit, soft-delete + p
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|---|---|---|---|---|---|---|
| **Uniform living-person redaction across child resources** | `person_visibility` now runs for non-members on the event, media, name, and relationship endpoints (#46), which delegate to `public_view_service`. Remaining: the `citation`/`source` list endpoints still gate only on `can_view_tree`, so citations tied to a redacted living person are still enumerable. | Partial | High | S | 12 | **Mostly resolved (NN#3/NN#2).** Apply `person_visibility` to the citation/source list paths to close the residual leak. |
| **Uniform living-person redaction across child resources** | `person_visibility` now runs for non-members on the event, media, name, relationship endpoints (#46) and the citation/source list endpoints, all delegating to `public_view_service`: citations resolve to FULL-visibility person(s); sources show only when they back a visible citation. | Have | High | S | 12 | **Resolved (NN#3/NN#2).** No child-resource path leaks a redacted living person's facts. |
| **Email-verification enforcement gate** | Read-side check now ships (#53): `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (`auth_service.py`). Opt-in (default off) so SMTP-less self-hosts still work. | Have | **High** | S | 12 | Read-side trust path now enforced (NN#7); the registration-mode switch below is the separate 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. |
| Instance owner / operator role | `OWNER_EMAIL`-declared operator (#240): `is_instance_owner` on `/users/me`, owner-only `GET /api/v1/admin/instance`, `/admin` UI. | Have | Med | S | 2/5 | Owner-only operational surface, twelve-factor via env (NN#7); reads stay through the service layer. |
@@ -412,7 +411,7 @@ Ordered by leverage. All are S-effort or a thin slice of a larger item, and most
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 and the i18n "from day one" claim match reality. The repo convention requires docs to travel with code.
> **Mostly shipped this cycle (#46):** the **media privacy leak** (§2.4) and the broad **child-resource redaction gap** (§2.10) are now closed for the person/event/media/name/relationship endpoints. The narrowed remainder — applying `person_visibility` to the **citation/source list endpoints** — is an S-effort follow-up; treat it as a security-priority Phase 12 fix regardless of the quick-win list.
> **Shipped this cycle:** the **media privacy leak** (§2.4) and the **child-resource redaction gap** (§2.10) are fully closed person/event/media/name/relationship (#46) and citation/source endpoints all apply `person_visibility` for non-members. No residual living-person leak on the read surface.
---
@@ -426,6 +425,6 @@ Where to invest to make Provenance distinct rather than a webtrees clone. Each l
**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, a one-command operator backup, and (to-build) scheduled off-host backup + ARM support make Provenance the genealogy app you actually own. The two correctness items that gated the promise have **landed**: GEDCOM export now preserves citations (the Provenance→Provenance round-trip keeps the sources graph), and operator backup moved from "documented procedure" to a one-command dump (`deploy/backup.sh`). What remains is scheduled/verified-restore tooling and ARM builds. 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.
**4. True self-hosting + data ownership.** Full account export/import, soft-delete recovery (with owner-confirmed on-demand purge to delete a trashed tree immediately rather than waiting out the 30-day window), GEDCOM round-trip, env-driven everything, a one-command operator backup, and (to-build) scheduled off-host backup + ARM support make Provenance the genealogy app you actually own. The two correctness items that gated the promise have **landed**: GEDCOM export now preserves citations (the Provenance→Provenance round-trip keeps the sources graph), and operator backup moved from "documented procedure" to a one-command dump (`deploy/backup.sh`). What remains is scheduled/verified-restore tooling and ARM builds. 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, and citations now **survive GEDCOM export** (#232); the remaining differentiator is making sourcing *visible and low-friction*: a guided Evidence-Explained citation builder, transcription/abstract fields, source-driven data entry (transcribe a document into the tree), and per-fact confidence surfaced in the UI. These turn "every fact links to where it came from" from an architecture note into the product's personality.
+77
View File
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Deceased = components["schemas"]["DeceasedCandidate"];
type DeceasedByChild = components["schemas"]["DeceasedByChildCandidate"];
type GenderProp = components["schemas"]["GenderProposal"];
type NameIssue = components["schemas"]["NameIssue"];
type Person = components["schemas"]["PersonRead"];
@@ -31,6 +32,12 @@ export default function CleanupPage() {
const [decSel, setDecSel] = useState<Set<string>>(new Set());
const [decMsg, setDecMsg] = useState<string | null>(null);
// 1b) Deceased by a child's birth year (for parents with no birth date)
const [childYear, setChildYear] = useState(1900);
const [decByChild, setDecByChild] = useState<DeceasedByChild[] | null>(null);
const [dbcSel, setDbcSel] = useState<Set<string>>(new Set());
const [dbcMsg, setDbcMsg] = useState<string | null>(null);
// 2) Gender from source GEDCOM
const [gender, setGender] = useState<GenderProp[] | null>(null);
const [genSel, setGenSel] = useState<Set<string>>(new Set());
@@ -63,6 +70,23 @@ export default function CleanupPage() {
setDeceased(null);
}
async function previewDeceasedByChild() {
setDbcMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased-by-child", {
params: { path: { tree_id: treeId }, query: { born_on_or_before: childYear } },
});
setDecByChild(data ?? []);
setDbcSel(new Set((data ?? []).map((d) => d.person_id)));
}
async function applyDeceasedByChild() {
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId } },
body: { person_ids: [...dbcSel] },
});
setDbcMsg(`Marked ${data?.updated ?? 0} people deceased.`);
setDecByChild(null);
}
async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (genFile.current) genFile.current.value = "";
@@ -231,6 +255,59 @@ export default function CleanupPage() {
</CardContent>
</Card>
{/* 1b) Deceased by a child's birth year */}
<Card>
<CardHeader>
<CardTitle className="text-base">Mark deceased by a childs birth year</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Catches parents who have <strong>no birth date of their own</strong> (so the rule
above cant reach them) but who have a child born long ago theyre necessarily
deceased.
</p>
<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)]">Has a child born on or before</span>
<Input
type="number"
className="w-28"
value={childYear}
onChange={(e) => setChildYear(Number(e.target.value))}
/>
</label>
<Button variant="outline" onClick={previewDeceasedByChild}>
Preview
</Button>
</div>
{dbcMsg && <p className="text-sm text-bronze">{dbcMsg}</p>}
{decByChild && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{decByChild.length} people with a child born {childYear} (not already marked
deceased).
</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{decByChild.map((d) => (
<li key={d.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={dbcSel.has(d.person_id)}
onChange={() => toggle(dbcSel, d.person_id, setDbcSel)}
/>
<span className="flex-1">{d.name}</span>
<span className="text-xs text-[var(--muted)]">child b. {d.child_birth_year}</span>
</li>
))}
</ul>
{decByChild.length > 0 && (
<Button onClick={applyDeceasedByChild}>Mark {dbcSel.size} deceased</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* 2) Gender from source */}
<Card>
<CardHeader>
@@ -135,7 +135,6 @@ export default function PersonDetailPage() {
const [evType, setEvType] = useState("birth");
const [evTypeOther, setEvTypeOther] = useState("");
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
const [allEvents, setAllEvents] = useState<Event[]>([]); // tree-wide, for partnership events
const [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState("");
@@ -189,8 +188,9 @@ export default function PersonDetailPage() {
return;
}
setPerson(p.data ?? null);
const [all, nm, mine, tr, ev, rl, src, cit, evAll, med] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
// Person-scoped fetches only — the page no longer pulls the whole tree.
// /persons/{id}/events now includes this person's partnership events too.
const [nm, mine, tr, ev, rl, src, cit, med] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
@@ -204,22 +204,49 @@ export default function PersonDetailPage() {
}),
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/citations", { 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}/media", { params: { path: { tree_id: treeId } } }),
]);
setPeople(all.data ?? []);
setNames(nm.data ?? []);
setMe(mine.data ?? null);
setTree(tr.data ?? null);
setEvents(ev.data ?? []);
setAllEvents(evAll.data ?? []);
setMedia(med.data ?? []);
setRels(rl.data ?? []);
setSources(src.data ?? []);
setCitations(cit.data ?? []);
// Resolve the names of just this person's relatives (for display), by id —
// not the whole tree. The relationship/spouse pickers search on demand.
const relList = rl.data ?? [];
const relatedIds = Array.from(
new Set(
relList
.flatMap((r) => [r.person_from_id, r.person_to_id])
.filter((id): id is string => !!id && id !== personId),
),
);
if (relatedIds.length) {
const rel = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { ids: relatedIds.join(",") } },
});
setPeople(rel.data ?? []);
} else {
setPeople([]);
}
setReady(true);
}, [router, treeId, personId]);
// Server-side fuzzy search for the relative/spouse pickers — avoids loading
// every person just to search.
const searchPeople = useCallback(
async (query: string) => {
const r = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { q: query } },
});
return (r.data ?? []).filter((pp) => pp.id !== personId);
},
[treeId, personId],
);
useEffect(() => {
load();
}, [load]);
@@ -233,7 +260,6 @@ export default function PersonDetailPage() {
return (id: string) => m.get(id) ?? "source";
}, [sources]);
const others = people.filter((p) => p.id !== personId);
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");
@@ -241,22 +267,18 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
// Partnership events live on the relationship and show on both partners.
// Partnership events live on the relationship and show on both partners; the
// /persons/{id}/events endpoint now returns them alongside personal events.
const myPartnerRels = rels.filter(
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
);
const myPartnerRelIds = new Set(myPartnerRels.map((r) => r.id));
const relEvents = allEvents.filter(
(e) => e.relationship_id && myPartnerRelIds.has(e.relationship_id),
);
const spouseOfRelEvent = (relId: string | null | undefined) => {
const r = myPartnerRels.find((x) => x.id === relId);
if (!r) return null;
return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
};
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
// Personal events + this person's partnership events, shown together.
const shownEvents = [...events, ...relEvents];
const shownEvents = events;
async function addEvent(e: React.FormEvent) {
e.preventDefault();
@@ -1090,7 +1112,7 @@ export default function PersonDetailPage() {
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
<PersonCombobox
people={others}
onSearch={searchPeople}
value={evSpouse}
onChange={setEvSpouse}
placeholder="Search for a spouse…"
@@ -1158,36 +1180,32 @@ export default function PersonDetailPage() {
</div>
)}
{others.length === 0 ? (
<p className="text-sm text-[var(--muted)]">Add more people to the tree to link them.</p>
) : (
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--muted)]">Add</span>
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
<option value="parent">parent</option>
<option value="child">child</option>
<option value="partner">partner</option>
<option value="sibling">sibling</option>
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--muted)]">Add</span>
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
<option value="parent">parent</option>
<option value="child">child</option>
<option value="partner">partner</option>
<option value="sibling">sibling</option>
</select>
<PersonCombobox
onSearch={searchPeople}
value={relOther}
onChange={setRelOther}
onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
<PersonCombobox
people={others}
value={relOther}
onChange={setRelOther}
onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
)}
<Button type="submit">Link</Button>
</form>
)}
)}
<Button type="submit">Link</Button>
</form>
{relErr && <p className="text-sm text-red-600">{relErr}</p>}
</CardContent>
</Card>
+21 -2
View File
@@ -1,7 +1,10 @@
.f3 {
--female-color: rgb(196, 138, 146);
--male-color: rgb(120, 159, 172);
--genderless-color: lightgray;
/* Warm mid-gray for unset-sex / redacted "Living person" cards matches the
muted male/female tone weight and the brand palette, instead of the library's
washed-out lightgray. */
--genderless-color: rgb(156, 150, 143);
--background-color: rgb(33, 33, 33);
--text-color: #fff;
@@ -381,9 +384,25 @@
color: rgb(255, 251, 220);
background-color: rgba(255, 251, 220, 0);
border-radius: 50%;
padding: 2px;
padding: 2px 4px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
}
.f3 .f3-card-duplicate-tag:hover {
background-color: rgba(255, 251, 220, 0.9);
color: #000;
}
/* Click the ×N badge → every copy of that person flashes (see tree/page.tsx). */
@keyframes f3-card-flash {
0%, 100% { outline-color: rgba(160, 106, 66, 0); }
30%, 70% { outline-color: rgba(160, 106, 66, 1); }
}
.f3 .f3-card-flash .card-inner {
outline: 4px solid rgba(160, 106, 66, 1);
animation: f3-card-flash 0.55s ease-in-out 3;
}
.f3 .f3-card-duplicate-hover div.card-inner {
transform: translate(0, -2px);
+129 -6
View File
@@ -36,6 +36,12 @@ export default function TreePage() {
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null);
// family-chart's pan/zoom helpers (cardToMiddle, getCurrentZoom), captured at
// render — used to fly to a duplicate's other copy.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlersRef = useRef<any>(null);
// Per-person cursor so repeated clicks on a ×N badge cycle through the copies.
const dupCycle = useRef<Map<string, number>>(new Map());
const [query, setQuery] = useState("");
const [people, setPeople] = useState<Person[]>([]);
@@ -179,7 +185,9 @@ export default function TreePage() {
"first name": fn || "Unnamed",
"last name": ln,
birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
// male → blue, female → pink, unset → genderless (gray). Unset sex no
// longer defaults to male/blue (which was misleading).
gender: pp.gender === "male" ? "M" : pp.gender === "female" ? "F" : null,
},
rels: {
spouses: ok(partnersOf(pp.id), pp.id),
@@ -189,6 +197,7 @@ export default function TreePage() {
};
});
const f3 = await import("family-chart");
handlersRef.current = f3.handlers;
if (cancelled || !containerRef.current) return;
try {
containerRef.current.innerHTML = "";
@@ -252,6 +261,85 @@ export default function TreePage() {
[mode],
);
// Click the "×N" duplicate badge to FLY to the person's other copy in the
// view (cycling through them on repeat clicks) and flash it on arrival. The
// same record is drawn in two places (a shared ancestor, or an intermarriage),
// and on a big tree the other copy is usually off-screen. Delegated on the
// container so it survives chart rebuilds; capture-phase + stopPropagation so a
// badge click flies instead of recentering.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = (n: Element | null) => (n as any)?.__data__;
const idOf = (n: Element | null) => data(n)?.data?.id as string | undefined;
const xyOf = (cont: Element): { x: number; y: number } | null => {
const d = data(cont);
if (d && typeof d.x === "number" && typeof d.y === "number") return { x: d.x, y: d.y };
const m = /translate\(\s*([-\d.]+)[ ,]+([-\d.]+)/.exec(cont.getAttribute("transform") ?? "");
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
};
const flash = (cont: Element | null) => {
const card = cont?.querySelector(".card") as HTMLElement | null;
if (!card) return;
card.classList.remove("f3-card-flash");
void card.offsetWidth; // restart the animation on repeat clicks
card.classList.add("f3-card-flash");
window.setTimeout(() => card.classList.remove("f3-card-flash"), 1900);
};
function onClick(e: MouseEvent) {
const tag = (e.target as HTMLElement).closest?.(".f3-card-duplicate-tag");
if (!tag) return;
e.preventDefault();
e.stopPropagation();
const clicked = tag.closest(".card_cont");
const id = idOf(clicked);
if (!id) return;
const copies = Array.from(el!.querySelectorAll(".card_cont")).filter((c) => idOf(c) === id);
if (copies.length < 2) {
flash(clicked);
return;
}
// Advance from wherever we last landed (or the clicked card), skipping the
// clicked copy, so each click moves to the next other location.
const start = dupCycle.current.get(id) ?? copies.indexOf(clicked as Element);
let next = (start + 1) % copies.length;
if (copies[next] === clicked) next = (next + 1) % copies.length;
dupCycle.current.set(id, next);
const target = copies[next];
const handlers = handlersRef.current;
const svg = el!.querySelector("svg.main_svg") as SVGSVGElement | null;
const xy = xyOf(target);
let flew = false;
if (handlers?.cardToMiddle && svg && xy) {
try {
const rect = svg.getBoundingClientRect();
const scale = handlers.getCurrentZoom ? handlers.getCurrentZoom(svg).k : 1;
// cardToMiddle centers the datum at the current zoom. (Its vertical
// centering at non-1 zoom is fixed in our family-chart patch — see
// CLAUDE.md / upstream PR donatso/family-chart#103 — so we pass the
// raw y; do NOT pre-scale it here or it double-corrects.)
handlers.cardToMiddle({
datum: xy,
svg,
svg_dim: { width: rect.width, height: rect.height },
scale,
transition_time: 750,
});
flew = true;
} catch {
/* zoom not ready — fall back to flashing in place */
}
}
// Flash on arrival (after the fly), or immediately if we couldn't fly.
window.setTimeout(() => flash(target), flew ? 900 : 0);
}
el.addEventListener("click", onClick, true);
return () => el.removeEventListener("click", onClick, true);
}, []);
// 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.
@@ -402,11 +490,46 @@ export default function TreePage() {
/>
)}
<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 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--muted)]">
<span>
{mode === "fan"
? "Click an ancestor to recenter the fan."
: "Drag to pan · scroll to zoom · click a person to recenter."}
</span>
{mode !== "fan" && (
<div className="group relative">
<button
type="button"
className="underline decoration-dotted underline-offset-2 hover:text-bronze focus-visible:text-bronze focus-visible:outline-none"
>
Legend
</button>
<div className="invisible absolute bottom-full left-0 z-30 mb-2 w-80 rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3 text-xs text-[var(--foreground)] opacity-0 shadow-lg transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
<ul className="space-y-2">
<li>
<span className="font-semibold text-bronze">×N</span> on a card this
person appears N times in the current view. The same record is drawn in
two places because they connect through more than one line (a shared
ancestor, or an intermarriage).{" "}
<span className="text-[var(--muted)]">Click the ×N to fly to the other copies (click again to cycle).</span>
</li>
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "rgb(120,159,172)" }} /> male
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "rgb(196,138,146)" }} /> female
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "lightgray" }} /> sex not set
</span>
</li>
<li>Drag to pan, scroll to zoom, and click any card to recenter the tree on that person.</li>
</ul>
</div>
</div>
)}
</div>
</div>
);
}
+35 -2
View File
@@ -53,6 +53,26 @@ export default function TreesPage() {
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
load();
}
async function purge(id: string, treeName: string) {
const typed = window.prompt(
`Permanently delete "${treeName}" and ALL its data (people, sources, media, …)?\n\n` +
"This CANNOT be undone. Type the tree name to confirm:",
);
if (typed == null) return; // cancelled
const { error, response } = await api.POST("/api/v1/trees/{tree_id}/purge", {
params: { path: { tree_id: id } },
body: { confirm_name: typed },
});
if (error) {
window.alert(
response.status === 403
? "The name didn't match — nothing was deleted."
: "Couldn't purge that tree.",
);
return;
}
load();
}
// Optimistic visibility change so the dropdown reflects the pick immediately.
async function setVisibility(id: string, visibility: NonNullable<Tree["visibility"]>) {
setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t)));
@@ -139,15 +159,28 @@ export default function TreesPage() {
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
Recently deleted
</h2>
<p className="text-xs text-[var(--muted)]">
Restorable for 30 days, after which they&apos;re purged automatically. Use
Delete forever to purge one now.
</p>
<ul className="space-y-2">
{deleted.map((tree) => (
<li key={tree.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{tree.name}</span>
<CardContent className="flex items-center justify-between gap-2 p-4">
<span className="min-w-0 flex-1 truncate text-[var(--muted)]">{tree.name}</span>
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
Restore
</Button>
<Button
variant="outline"
size="sm"
onClick={() => purge(tree.id, tree.name)}
className="border-bronze/40 text-bronze hover:bg-bronze/10"
title="Permanently delete this tree and all its data"
>
Delete forever
</Button>
</CardContent>
</Card>
</li>
+63 -19
View File
@@ -1,26 +1,30 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { components } from "@/lib/api/schema";
type Person = components["schemas"]["PersonRead"];
/**
* A type-to-filter person picker. Shows a text input; as you type, a dropdown
* of matching people appears. Selecting one sets `value` (a person id) and
* fills the input with their name. Replaces a plain <select> when the list is
* long enough that scanning it by hand is painful.
* A type-to-pick person picker. Two modes:
* - client (`people`): filter a preloaded list in the browser.
* - server (`onSearch`): query the backend (debounced) as you type the
* preferred mode for large trees, so the page doesn't
* have to preload every person just to search.
* Selecting one sets `value` (a person id) and fills the input with their name.
*/
export function PersonCombobox({
people,
onSearch,
value,
onChange,
onCreate,
placeholder = "Search for a person…",
className,
}: {
people: Person[];
people?: Person[];
onSearch?: (q: string) => Promise<Person[]>;
value: string;
onChange: (id: string) => void;
/** When set, the dropdown offers a "Create '<typed name>'" action. */
@@ -30,21 +34,27 @@ export function PersonCombobox({
}) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [results, setResults] = useState<Person[]>([]);
const [loading, setLoading] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
// Names we've seen (from the list or search results), so a selected value
// keeps displaying its name even in server mode.
const known = useRef<Map<string, string>>(new Map());
const nameOf = useMemo(
() => new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])),
[people],
);
const remember = useCallback((ps: Person[] | undefined) => {
for (const p of ps ?? []) known.current.set(p.id, p.primary_name ?? "Unnamed");
}, []);
useEffect(() => {
remember(people);
}, [people, remember]);
const nameOf = useCallback((id: string) => known.current.get(id) ?? "", []);
// Keep the input text in sync when the selection changes externally
// (e.g. cleared to "" after a successful add).
useEffect(() => {
if (!value) {
setQuery("");
} else if (!open) {
setQuery(nameOf.get(value) ?? "");
}
if (!value) setQuery("");
else if (!open) setQuery(nameOf(value));
}, [value, open, nameOf]);
// Close on outside click.
@@ -56,17 +66,48 @@ export function PersonCombobox({
return () => document.removeEventListener("mousedown", onDoc);
}, []);
// Server search, debounced. Stale responses are dropped via `cancelled`.
useEffect(() => {
if (!onSearch) return;
const q = query.trim();
if (!q) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
let cancelled = false;
const t = setTimeout(async () => {
try {
const r = await onSearch(q);
if (cancelled) return;
remember(r);
setResults(r);
} finally {
if (!cancelled) setLoading(false);
}
}, 160);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [query, onSearch, remember]);
const matches = useMemo(() => {
if (onSearch) return results.slice(0, 10);
const q = query.trim().toLowerCase();
const pool = q
? people.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
: people;
? (people ?? []).filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
: (people ?? []);
return pool.slice(0, 10);
}, [query, people]);
}, [query, results, people, onSearch]);
const base =
"h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40";
const showDropdown =
open && (matches.length > 0 || loading || (onCreate && query.trim()));
return (
<div ref={wrapRef} className="relative">
<input
@@ -80,8 +121,11 @@ export function PersonCombobox({
if (value) onChange(""); // typing invalidates the prior pick
}}
/>
{open && (matches.length > 0 || (onCreate && query.trim())) && (
{showDropdown && (
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{loading && matches.length === 0 && (
<li className="px-3 py-2 text-sm text-[var(--muted)]">Searching</li>
)}
{matches.map((p) => (
<li key={p.id}>
<button
+4 -1
View File
@@ -120,7 +120,10 @@ export function PublicTreeChart({
"first name": fn || "Unnamed",
"last name": ln,
birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
// male → blue, female → pink, unset/redacted → genderless (gray).
// Redacted living people have null gender, so they render gray rather
// than defaulting to male/blue (and never imply a real person's sex).
gender: pp.gender === "male" ? "M" : pp.gender === "female" ? "F" : null,
},
rels: {
spouses: ok(partnersOf(pp.id), pp.id),
+126
View File
@@ -293,6 +293,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/purge": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Purge Tree
* @description Permanently delete a soft-deleted tree and all its data irreversible.
* Owner-only; the tree must be in the trash and `confirm_name` must match.
*/
post: operations["purge_tree_api_v1_trees__tree_id__purge_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/persons": {
parameters: {
query?: never;
@@ -697,6 +718,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/cleanup/deceased-by-child": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Preview Deceased By Child
* @description People with a child born on/before the cutoff necessarily deceased even
* when their own birth date is missing. Apply via POST .../cleanup/deceased.
*/
get: operations["preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/cleanup/gender/preview": {
parameters: {
query?: never;
@@ -1265,6 +1307,18 @@ export interface components {
/** Person Ids */
person_ids: string[];
};
/** DeceasedByChildCandidate */
DeceasedByChildCandidate: {
/**
* Person Id
* Format: uuid
*/
person_id: string;
/** Name */
name: string;
/** Child Birth Year */
child_birth_year: number;
};
/** DeceasedCandidate */
DeceasedCandidate: {
/**
@@ -1977,6 +2031,11 @@ export interface components {
/** @default private */
visibility?: components["schemas"]["TreeVisibility"];
};
/** TreePurge */
TreePurge: {
/** Confirm Name */
confirm_name: string;
};
/** TreeRead */
TreeRead: {
/**
@@ -2655,11 +2714,45 @@ export interface operations {
};
};
};
purge_tree_api_v1_trees__tree_id__purge_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TreePurge"];
};
};
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_persons_api_v1_trees__tree_id__persons_get: {
parameters: {
query?: {
deleted?: boolean;
q?: string | null;
ids?: string | null;
};
header?: never;
path: {
@@ -3953,6 +4046,39 @@ export interface operations {
};
};
};
preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get: {
parameters: {
query?: {
born_on_or_before?: number;
};
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeceasedByChildCandidate"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post: {
parameters: {
query?: never;
+158
View File
@@ -710,6 +710,53 @@
}
}
},
"/api/v1/trees/{tree_id}/purge": {
"post": {
"tags": [
"trees"
],
"summary": "Purge Tree",
"description": "Permanently delete a soft-deleted tree and all its data \u2014 irreversible.\nOwner-only; the tree must be in the trash and `confirm_name` must match.",
"operationId": "purge_tree_api_v1_trees__tree_id__purge_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TreePurge"
}
}
}
},
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/persons": {
"post": {
"tags": [
@@ -804,6 +851,22 @@
],
"title": "Q"
}
},
{
"name": "ids",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Ids"
}
}
],
"responses": {
@@ -2810,6 +2873,64 @@
}
}
},
"/api/v1/trees/{tree_id}/cleanup/deceased-by-child": {
"get": {
"tags": [
"cleanup"
],
"summary": "Preview Deceased By Child",
"description": "People with a child born on/before the cutoff \u2014 necessarily deceased even\nwhen their own birth date is missing. Apply via POST .../cleanup/deceased.",
"operationId": "preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "born_on_or_before",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1900,
"title": "Born On Or Before"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeceasedByChildCandidate"
},
"title": "Response Preview Deceased By Child Api V1 Trees Tree Id Cleanup Deceased By Child Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/cleanup/gender/preview": {
"post": {
"tags": [
@@ -4834,6 +4955,30 @@
],
"title": "DeceasedApply"
},
"DeceasedByChildCandidate": {
"properties": {
"person_id": {
"type": "string",
"format": "uuid",
"title": "Person Id"
},
"name": {
"type": "string",
"title": "Name"
},
"child_birth_year": {
"type": "integer",
"title": "Child Birth Year"
}
},
"type": "object",
"required": [
"person_id",
"name",
"child_birth_year"
],
"title": "DeceasedByChildCandidate"
},
"DeceasedCandidate": {
"properties": {
"person_id": {
@@ -7101,6 +7246,19 @@
],
"title": "TreeCreate"
},
"TreePurge": {
"properties": {
"confirm_name": {
"type": "string",
"title": "Confirm Name"
}
},
"type": "object",
"required": [
"confirm_name"
],
"title": "TreePurge"
},
"TreeRead": {
"properties": {
"id": {
+20 -2
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/family-chart/dist/family-chart.esm.js b/node_modules/family-chart/dist/family-chart.esm.js
index 3867be0..560c99e 100644
index 3867be0..656fafa 100644
--- a/node_modules/family-chart/dist/family-chart.esm.js
+++ b/node_modules/family-chart/dist/family-chart.esm.js
@@ -10,10 +10,10 @@ function sortChildrenWithSpouses(children, datum, data) {
@@ -61,8 +61,17 @@ index 3867be0..560c99e 100644
if (!d.spouses)
d.spouses = [];
d.spouses.push(spouse);
@@ -1073,7 +1091,7 @@ function calculateTreeFit(svg_dim, tree_dim) {
return { k, x, y };
}
function cardToMiddle({ datum, svg, svg_dim, scale, transition_time }) {
- const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y, t = { k, x: x / k, y: y / k };
+ const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y * k, t = { k, x: x / k, y: y / k };
positionTree({ t, svg, transition_time });
}
function manualZoom({ amount, svg, transition_time = 500 }) {
diff --git a/node_modules/family-chart/dist/family-chart.js b/node_modules/family-chart/dist/family-chart.js
index 1c750d4..47efcc2 100644
index 1c750d4..edeb804 100644
--- a/node_modules/family-chart/dist/family-chart.js
+++ b/node_modules/family-chart/dist/family-chart.js
@@ -33,10 +33,9 @@
@@ -116,3 +125,12 @@ index 1c750d4..47efcc2 100644
if (!d.spouses)
d.spouses = [];
d.spouses.push(spouse);
@@ -1096,7 +1106,7 @@
return { k, x, y };
}
function cardToMiddle({ datum, svg, svg_dim, scale, transition_time }) {
- const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y, t = { k, x: x / k, y: y / k };
+ const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y * k, t = { k, x: x / k, y: y / k };
positionTree({ t, svg, transition_time });
}
function manualZoom({ amount, svg, transition_time = 500 }) {