From f2205b93f4825e54632e5a9f731626e732edb430 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 22:19:01 -0400 Subject: [PATCH 1/2] Add soft-delete + recovery and tree-wide graph endpoints Tree and person soft-delete + restore (owner-only for trees, editor for people) with recovery listings (?deleted=true); the worker already purges past the 30-day window. Adds tree-wide GET /relationships and /events so the family/pedigree view loads the whole graph in a few calls. 27 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/v1/events.py | 9 +++ backend/app/api/v1/persons.py | 28 +++++++- backend/app/api/v1/relationships.py | 9 +++ backend/app/api/v1/trees.py | 20 +++++- backend/app/services/event_service.py | 14 ++++ backend/app/services/person_service.py | 72 ++++++++++++++++++++ backend/app/services/relationship_service.py | 14 ++++ backend/app/services/tree_service.py | 53 ++++++++++++++ backend/tests/test_recovery.py | 54 +++++++++++++++ 9 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_recovery.py diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 90b73c6..928cfd3 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -20,6 +20,15 @@ async def create_event( return EventRead.model_validate(event) +@router.get("/{tree_id}/events", response_model=list[EventRead]) +async def list_tree_events( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[EventRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + events = await event_service.list_events(session, viewer_id=current.id, tree=tree) + return [EventRead.model_validate(e) for e in events] + + @router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead]) async def list_person_events( tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/api/v1/persons.py b/backend/app/api/v1/persons.py index 913d266..43f66a0 100644 --- a/backend/app/api/v1/persons.py +++ b/backend/app/api/v1/persons.py @@ -36,13 +36,37 @@ async def create_person( @router.get("/{tree_id}/persons", response_model=list[PersonRead]) async def list_persons( - tree_id: uuid.UUID, session: SessionDep, current: CurrentUser + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, deleted: bool = False ) -> list[PersonRead]: tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) - persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) + if deleted: + persons = await person_service.list_deleted_persons( + session, viewer_id=current.id, tree=tree + ) + else: + persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) return [PersonRead.model_validate(p) for p in persons] +@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_person( + tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> None: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id) + + +@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead) +async def restore_person( + tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> PersonRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + person = await person_service.restore_person( + session, actor=current, tree=tree, person_id=person_id + ) + return PersonRead.model_validate(person) + + @router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead) async def get_person( tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/api/v1/relationships.py b/backend/app/api/v1/relationships.py index dab0146..dbd4456 100644 --- a/backend/app/api/v1/relationships.py +++ b/backend/app/api/v1/relationships.py @@ -24,6 +24,15 @@ async def create_relationship( return RelationshipRead.model_validate(relationship) +@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead]) +async def list_relationships( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[RelationshipRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree) + return [RelationshipRead.model_validate(r) for r in rels] + + @router.get( "/{tree_id}/persons/{person_id}/relationships", response_model=list[RelationshipRead], diff --git a/backend/app/api/v1/trees.py b/backend/app/api/v1/trees.py index 05ea73b..ab68530 100644 --- a/backend/app/api/v1/trees.py +++ b/backend/app/api/v1/trees.py @@ -22,8 +22,13 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse @router.get("", response_model=list[TreeRead]) -async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]: - trees = await tree_service.list_trees_for_user(session, user=current) +async def list_my_trees( + session: SessionDep, current: CurrentUser, deleted: bool = False +) -> list[TreeRead]: + if deleted: + trees = await tree_service.list_deleted_trees_for_user(session, user=current) + else: + trees = await tree_service.list_trees_for_user(session, user=current) return [TreeRead.model_validate(t) for t in trees] @@ -31,3 +36,14 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) return TreeRead.model_validate(tree) + + +@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None: + await tree_service.delete_tree(session, actor=current, tree_id=tree_id) + + +@router.post("/{tree_id}/restore", response_model=TreeRead) +async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: + tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id) + return TreeRead.model_validate(tree) diff --git a/backend/app/services/event_service.py b/backend/app/services/event_service.py index dbfdb3a..ebde8a8 100644 --- a/backend/app/services/event_service.py +++ b/backend/app/services/event_service.py @@ -91,6 +91,20 @@ async def create_event( return event +async def list_events( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree +) -> list[Event]: + """All events in the tree — lets the family view compute birth/death years.""" + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + stmt = ( + select(Event) + .where(Event.tree_id == tree.id, Event.deleted_at.is_(None)) + .order_by(Event.date_start.nulls_last(), Event.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + async def list_events_for_person( session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID ) -> list[Event]: diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py index 6338f71..78ba7e5 100644 --- a/backend/app/services/person_service.py +++ b/backend/app/services/person_service.py @@ -4,6 +4,7 @@ person through the privacy engine. Each returned Person gets a transient """ import uuid +from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -112,6 +113,77 @@ async def get_person( return person +async def delete_person( + session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID +) -> None: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + person = ( + await session.execute( + select(Person).where( + Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None) + ) + ) + ).scalar_one_or_none() + if person is None: + raise NotFound("person not found") + person.deleted_at = datetime.now(UTC) + record_audit( + session, + action="delete", + entity_type="Person", + entity_id=person.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() + + +async def restore_person( + session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID +) -> Person: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + person = ( + await session.execute( + select(Person).where( + Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None) + ) + ) + ).scalar_one_or_none() + if person is None: + raise NotFound("deleted person not found") + person.deleted_at = None + record_audit( + session, + action="restore", + entity_type="Person", + entity_id=person.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() + await session.refresh(person) + await _attach_primary_name(session, person) + return person + + +async def list_deleted_persons( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree +) -> list[Person]: + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + stmt = ( + select(Person) + .where(Person.tree_id == tree.id, Person.deleted_at.is_not(None)) + .order_by(Person.deleted_at.desc()) + ) + persons = list((await session.execute(stmt)).scalars().all()) + for person in persons: + await _attach_primary_name(session, person) + return persons + + async def list_persons( session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree ) -> list[Person]: diff --git a/backend/app/services/relationship_service.py b/backend/app/services/relationship_service.py index 129a39a..a0e855c 100644 --- a/backend/app/services/relationship_service.py +++ b/backend/app/services/relationship_service.py @@ -73,6 +73,20 @@ async def create_relationship( return relationship +async def list_relationships( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree +) -> list[Relationship]: + """All relationships in the tree — powers the family/pedigree view in one call.""" + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + stmt = ( + select(Relationship) + .where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)) + .order_by(Relationship.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + async def list_relationships_for_person( session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID ) -> list[Relationship]: diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py index ffc4c59..f056d55 100644 --- a/backend/app/services/tree_service.py +++ b/backend/app/services/tree_service.py @@ -3,6 +3,7 @@ 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.ext.asyncio import AsyncSession @@ -59,3 +60,55 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): raise Forbidden("not permitted to view this tree") return tree + + +async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree: + """Load a tree (including soft-deleted) and require the actor be its owner.""" + tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True) + if tree is None: + raise NotFound("tree not found") + role = await privacy.get_membership_role(session, actor.id, tree.id) + if role is not MembershipRole.owner: + raise Forbidden("only the owner can delete or restore a tree") + return tree + + +async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None: + tree = await _owned_tree(session, actor=actor, tree_id=tree_id) + if tree.deleted_at is None: + tree.deleted_at = datetime.now(UTC) + record_audit( + session, + action="delete", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() + + +async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree: + tree = await _owned_tree(session, actor=actor, tree_id=tree_id) + if tree.deleted_at is not None: + tree.deleted_at = None + record_audit( + session, + action="restore", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() + return tree + + +async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]: + stmt = ( + select(Tree) + .join(TreeMembership, TreeMembership.tree_id == Tree.id) + .where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None)) + .order_by(Tree.deleted_at.desc()) + ) + return list((await session.execute(stmt)).scalars().all()) diff --git a/backend/tests/test_recovery.py b/backend/tests/test_recovery.py new file mode 100644 index 0000000..ff4a91e --- /dev/null +++ b/backend/tests/test_recovery.py @@ -0,0 +1,54 @@ +"""Soft-delete + recovery for trees and people.""" + +from tests.conftest import auth, register + + +async def test_tree_delete_and_restore(client): + h = auth(await register(client, "rec1@example.com")) + tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"] + + # Delete -> gone from active lists, present in the recovery list. + assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204 + assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0 + # A soft-deleted tree is no longer visible (404 to the would-be viewer). + gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h) + assert gone.status_code == 404 + deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json() + assert len(deleted) == 1 and deleted[0]["id"] == tree_id + + # Restore -> back in active lists. + assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200 + assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1 + assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200 + + +async def test_only_owner_can_delete_tree(client): + owner = auth(await register(client, "rec-owner@example.com")) + tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"] + other = auth(await register(client, "rec-other@example.com")) + blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other) + assert blocked.status_code in (403, 404) + + +async def test_person_delete_and_restore(client): + h = auth(await register(client, "rec2@example.com")) + tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"] + person_id = ( + await client.post( + f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h + ) + ).json()["id"] + + assert ( + await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h) + ).status_code == 204 + assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0 + deleted = ( + await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h) + ).json() + assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada" + + assert ( + await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h) + ).status_code == 200 + assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1 From 22bc536978f416dea557557c240d8e53cb0df763 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 22:19:01 -0400 Subject: [PATCH 2/2] Rebuild People as a family view (pedigree + family group); add recovery UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Paul --- frontend/app/trees/[id]/page.tsx | 347 +++++++++++++++--- .../trees/[id]/persons/[personId]/page.tsx | 14 +- frontend/app/trees/[id]/recovery/page.tsx | 72 ++++ frontend/app/trees/page.tsx | 78 ++-- frontend/components/app-sidebar.tsx | 8 +- frontend/lib/api/schema.d.ts | 243 +++++++++++- frontend/openapi.json | 346 +++++++++++++++-- 7 files changed, 1002 insertions(+), 106 deletions(-) create mode 100644 frontend/app/trees/[id]/recovery/page.tsx diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index 2a46cc2..32a4ad6 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -2,35 +2,60 @@ import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { api } from "@/lib/api/client"; import type { components } from "@/lib/api/schema"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; type Person = components["schemas"]["PersonRead"]; +type Relationship = components["schemas"]["RelationshipRead"]; +type Event = components["schemas"]["EventRead"]; -export default function TreeDetailPage() { +function splitName(full: string): { given: string | null; surname: string | null } { + const t = full.trim().split(/\s+/).filter(Boolean); + if (t.length === 0) return { given: null, surname: null }; + if (t.length === 1) return { given: t[0], surname: null }; + return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] }; +} + +type AddKind = "parent" | "child" | "partner"; + +export default function FamilyViewPage() { const router = useRouter(); const params = useParams<{ id: string }>(); const treeId = params.id; - const [persons, setPersons] = useState([]); - const [given, setGiven] = useState(""); - const [surname, setSurname] = useState(""); + const [people, setPeople] = useState([]); + const [rels, setRels] = useState([]); + const [events, setEvents] = useState([]); const [ready, setReady] = useState(false); + const [focusId, setFocusId] = useState(null); + const [search, setSearch] = useState(""); + const [firstName, setFirstName] = useState(""); + // Inline add-relative form: which anchor + kind is open, and the typed name. + const [adding, setAdding] = useState<{ kind: AddKind; anchor: string } | null>(null); + const [addName, setAddName] = useState(""); const load = useCallback(async () => { - const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { + const p = await api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } }, }); - if (response.status === 401) { + if (p.response.status === 401) { router.push("/login"); return; } - setPersons(data ?? []); + const [r, e] = await Promise.all([ + api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }), + api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }), + ]); + const ppl = p.data ?? []; + setPeople(ppl); + setRels(r.data ?? []); + setEvents(e.data ?? []); + setFocusId((cur) => cur ?? ppl[0]?.id ?? null); setReady(true); }, [router, treeId]); @@ -38,60 +63,276 @@ export default function TreeDetailPage() { load(); }, [load]); - async function addPerson(e: React.FormEvent) { - e.preventDefault(); - if (!given.trim() && !surname.trim()) return; - const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", { - params: { path: { tree_id: treeId } }, - body: { given: given || null, surname: surname || null }, - }); - if (!error) { - setGiven(""); - setSurname(""); - load(); + const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]); + const parentsOf = (id: string) => + rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id); + const childrenOf = (id: string) => + rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id); + const partnersOf = (id: string) => + rels + .filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id)) + .map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id)); + + const years = useMemo(() => { + const m = new Map(); + const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? ""); + for (const p of people) { + const b = events.find((e) => e.person_id === p.id && e.event_type === "birth"); + const d = events.find((e) => e.person_id === p.id && e.event_type === "death"); + const parts = [b ? yr(b) : "", d ? yr(d) : ""]; + if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, "")); } + return m; + }, [people, events]); + + async function addPerson(name: string): Promise { + const { given, surname } = splitName(name); + const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", { + params: { path: { tree_id: treeId } }, + body: { given, surname }, + }); + return data?.id ?? null; + } + + async function createFirst(e: React.FormEvent) { + e.preventDefault(); + if (!firstName.trim()) return; + const id = await addPerson(firstName); + setFirstName(""); + if (id) setFocusId(id); + load(); + } + + async function submitAdd(e: React.FormEvent) { + e.preventDefault(); + if (!adding || !addName.trim()) return; + const newId = await addPerson(addName); + if (newId) { + const { kind, anchor } = adding; + const body = + kind === "parent" + ? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const } + : kind === "child" + ? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const } + : { type: "partnership" as const, person_from_id: anchor, person_to_id: newId }; + await api.POST("/api/v1/trees/{tree_id}/relationships", { + params: { path: { tree_id: treeId } }, + body, + }); + } + setAdding(null); + setAddName(""); + load(); } if (!ready) return

Loading…

; - return ( -
-

People

+ if (people.length === 0) { + return ( +
+

Start your tree

+ + +
+ setFirstName(e.target.value)} + /> + +
+
+
+
+ ); + } + const focus = focusId ? byId.get(focusId) : undefined; + if (!focus) { + setFocusId(people[0].id); + return null; + } + + const PersonBox = ({ + id, + muted, + }: { + id: string; + muted?: boolean; + }) => { + const p = byId.get(id); + if (!p) return null; + const isFocus = id === focusId; + return ( + + ); + }; + + const AddSlot = ({ kind, anchor, label }: { kind: AddKind; anchor: string; label: string }) => + adding && adding.kind === kind && adding.anchor === anchor ? ( +
+ setAddName(e.target.value)} + /> +
+ + +
+
+ ) : ( + + ); + + const parents = parentsOf(focus.id); + const partners = partnersOf(focus.id); + const children = childrenOf(focus.id); + + const sorted = [...people].sort((a, b) => + (a.primary_name ?? "").localeCompare(b.primary_name ?? ""), + ); + const matches = search + ? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(search.toLowerCase())) + : sorted; + + return ( +
+
+

Family view

+ + Open {focus.primary_name ?? "person"} → + +
+ + {/* Pedigree: focus → parents → grandparents */} - - Add a person - - -
- setGiven(e.target.value)} /> - setSurname(e.target.value)} /> - -
+ +
+
+
+ Focus +
+ +
+ +
+
+ Parents +
+ {parents.map((pid) => ( + + ))} + {parents.length < 2 && } +
+ +
+
+ Grandparents +
+ {parents.length === 0 && ( +
Add parents first.
+ )} + {parents.map((pid) => ( +
+ {parentsOf(pid).map((gp) => ( + + ))} + {parentsOf(pid).length < 2 && ( + + )} +
+ ))} +
+
-
-

People

- {persons.length === 0 ? ( -

No people yet.

- ) : ( -
    - {persons.map((person) => ( -
  • - - - - {person.primary_name ?? ( - Unnamed - )} - - - -
  • - ))} -
- )} + {/* Family group: partners + children of the focus */} +
+ + +

Spouses & partners

+
+ {partners.map((id) => ( + + ))} + +
+
+
+ + + +

Children

+
+ {children.map((id) => ( + + ))} + +
+
+
+
+ + {/* Searchable index of everyone in the tree */} +
+
+

All people ({people.length})

+ setSearch(e.target.value)} + /> +
+
+ {matches.map((p) => ( + + ))} +
); diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index b615063..86722e7 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -206,6 +206,13 @@ export default function PersonDetailPage() { load(); } + async function removePerson() { + await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", { + params: { path: { tree_id: treeId, person_id: personId } }, + }); + router.push(`/trees/${treeId}`); + } + if (!ready) return

Loading…

; if (!person) return

Not found.

; @@ -311,7 +318,12 @@ export default function PersonDetailPage() {

{person.primary_name ?? "Unnamed person"}

- {citeControl("p", { person_id: personId }, personCites)} +
+ {citeControl("p", { person_id: personId }, personCites)} + +
diff --git a/frontend/app/trees/[id]/recovery/page.tsx b/frontend/app/trees/[id]/recovery/page.tsx new file mode 100644 index 0000000..4b6d5e6 --- /dev/null +++ b/frontend/app/trees/[id]/recovery/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type Person = components["schemas"]["PersonRead"]; + +export default function RecoveryPage() { + const router = useRouter(); + const params = useParams<{ id: string }>(); + const treeId = params.id; + + const [people, setPeople] = useState([]); + const [ready, setReady] = useState(false); + + const load = useCallback(async () => { + const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { + params: { path: { tree_id: treeId }, query: { deleted: true } }, + }); + if (response.status === 401) { + router.push("/login"); + return; + } + setPeople(data ?? []); + setReady(true); + }, [router, treeId]); + + useEffect(() => { + load(); + }, [load]); + + async function restore(id: string) { + await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/restore", { + params: { path: { tree_id: treeId, person_id: id } }, + }); + load(); + } + + if (!ready) return

Loading…

; + + return ( +
+

Recently deleted

+

+ Deleted people are recoverable for 30 days, then permanently purged. +

+ {people.length === 0 ? ( +

Nothing here.

+ ) : ( +
    + {people.map((p) => ( +
  • + + + {p.primary_name ?? "Unnamed"} + + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/app/trees/page.tsx b/frontend/app/trees/page.tsx index b2d9e52..ab401b2 100644 --- a/frontend/app/trees/page.tsx +++ b/frontend/app/trees/page.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api/client"; import type { components } from "@/lib/api/schema"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; type Tree = components["schemas"]["TreeRead"]; @@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"]; export default function TreesPage() { const router = useRouter(); const [trees, setTrees] = useState([]); + const [deleted, setDeleted] = useState([]); const [name, setName] = useState(""); const [ready, setReady] = useState(false); @@ -25,6 +26,8 @@ export default function TreesPage() { return; } setTrees(data ?? []); + const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } }); + setDeleted(del.data ?? []); setReady(true); }, [router]); @@ -42,24 +45,26 @@ export default function TreesPage() { } } + async function remove(id: string) { + await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } }); + load(); + } + async function restore(id: string) { + await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } }); + load(); + } + if (!ready) return

Loading…

; return ( -
+

Your trees

- - New tree - - +
- setName(e.target.value)} - /> - + setName(e.target.value)} /> +
@@ -67,23 +72,52 @@ export default function TreesPage() { {trees.length === 0 ? (

No trees yet — create your first one above.

) : ( -
    +
      {trees.map((tree) => (
    • - - - - {tree.name} - + + + +
      {tree.name}
      +
      {tree.visibility} - - - - +
      + + +
      +
    • ))}
    )} + + {deleted.length > 0 && ( +
    +

    + Recently deleted +

    +
      + {deleted.map((tree) => ( +
    • + + + {tree.name} + + + +
    • + ))} +
    +
    + )}
); } diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index 4631227..a9c42f7 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { BookText, FolderTree, Image as ImageIcon, LogOut, Users } from "lucide-react"; +import { Archive, BookText, FolderTree, Image as ImageIcon, LogOut, Users } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -87,6 +87,12 @@ export function AppSidebar() { icon={ImageIcon} active={pathname.startsWith(`/trees/${treeId}/media`)} /> +
)} diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index 22d8765..8f5cfb6 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -186,6 +186,24 @@ export interface paths { get: operations["get_tree_api_v1_trees__tree_id__get"]; put?: never; post?: never; + /** Delete Tree */ + delete: operations["delete_tree_api_v1_trees__tree_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/trees/{tree_id}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore Tree */ + post: operations["restore_tree_api_v1_trees__tree_id__restore_post"]; delete?: never; options?: never; head?: never; @@ -221,6 +239,24 @@ export interface paths { get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"]; put?: never; post?: never; + /** Delete Person */ + delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/trees/{tree_id}/persons/{person_id}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore Person */ + post: operations["restore_person_api_v1_trees__tree_id__persons__person_id__restore_post"]; delete?: never; options?: never; head?: never; @@ -234,7 +270,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** List Tree Events */ + get: operations["list_tree_events_api_v1_trees__tree_id__events_get"]; put?: never; /** Create Event */ post: operations["create_event_api_v1_trees__tree_id__events_post"]; @@ -285,7 +322,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** List Relationships */ + get: operations["list_relationships_api_v1_trees__tree_id__relationships_get"]; put?: never; /** Create Relationship */ post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"]; @@ -1169,7 +1207,9 @@ export interface operations { }; list_my_trees_api_v1_trees_get: { parameters: { - query?: never; + query?: { + deleted?: boolean; + }; header?: never; path?: never; cookie?: never; @@ -1185,6 +1225,15 @@ export interface operations { "application/json": components["schemas"]["TreeRead"][]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; create_tree_api_v1_trees_post: { @@ -1251,7 +1300,7 @@ export interface operations { }; }; }; - list_persons_api_v1_trees__tree_id__persons_get: { + delete_tree_api_v1_trees__tree_id__delete: { parameters: { query?: never; header?: never; @@ -1261,6 +1310,68 @@ export interface operations { cookie?: never; }; requestBody?: never; + 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"]; + }; + }; + }; + }; + restore_tree_api_v1_trees__tree_id__restore_post: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TreeRead"]; + }; + }; + /** @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; + }; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -1349,6 +1460,99 @@ export interface operations { }; }; }; + delete_person_api_v1_trees__tree_id__persons__person_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + 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"]; + }; + }; + }; + }; + restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_tree_events_api_v1_trees__tree_id__events_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; create_event_api_v1_trees__tree_id__events_post: { parameters: { query?: never; @@ -1446,6 +1650,37 @@ export interface operations { }; }; }; + list_relationships_api_v1_trees__tree_id__relationships_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RelationshipRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; create_relationship_api_v1_trees__tree_id__relationships_post: { parameters: { query?: never; diff --git a/frontend/openapi.json b/frontend/openapi.json index 2cbfbcc..439fce7 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -281,29 +281,6 @@ } }, "/api/v1/trees": { - "get": { - "tags": [ - "trees" - ], - "summary": "List My Trees", - "operationId": "list_my_trees_api_v1_trees_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/TreeRead" - }, - "type": "array", - "title": "Response List My Trees Api V1 Trees Get" - } - } - } - } - } - }, "post": { "tags": [ "trees" @@ -311,14 +288,14 @@ "summary": "Create Tree", "operationId": "create_tree_api_v1_trees_post", "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TreeCreate" } } - }, - "required": true + } }, "responses": { "201": { @@ -342,6 +319,51 @@ } } } + }, + "get": { + "tags": [ + "trees" + ], + "summary": "List My Trees", + "operationId": "list_my_trees_api_v1_trees_get", + "parameters": [ + { + "name": "deleted", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Deleted" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TreeRead" + }, + "title": "Response List My Trees Api V1 Trees Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/api/v1/trees/{tree_id}": { @@ -385,6 +407,83 @@ } } } + }, + "delete": { + "tags": [ + "trees" + ], + "summary": "Delete Tree", + "operationId": "delete_tree_api_v1_trees__tree_id__delete", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/trees/{tree_id}/restore": { + "post": { + "tags": [ + "trees" + ], + "summary": "Restore Tree", + "operationId": "restore_tree_api_v1_trees__tree_id__restore_post", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TreeRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/api/v1/trees/{tree_id}/persons": { @@ -455,6 +554,16 @@ "format": "uuid", "title": "Tree Id" } + }, + { + "name": "deleted", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Deleted" + } } ], "responses": { @@ -486,6 +595,50 @@ } }, "/api/v1/trees/{tree_id}/persons/{person_id}": { + "delete": { + "tags": [ + "persons" + ], + "summary": "Delete Person", + "operationId": "delete_person_api_v1_trees__tree_id__persons__person_id__delete", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "person_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Person Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, "get": { "tags": [ "persons" @@ -538,6 +691,59 @@ } } }, + "/api/v1/trees/{tree_id}/persons/{person_id}/restore": { + "post": { + "tags": [ + "persons" + ], + "summary": "Restore Person", + "operationId": "restore_person_api_v1_trees__tree_id__persons__person_id__restore_post", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "person_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Person Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/trees/{tree_id}/events": { "post": { "tags": [ @@ -589,6 +795,51 @@ } } } + }, + "get": { + "tags": [ + "events" + ], + "summary": "List Tree Events", + "operationId": "list_tree_events_api_v1_trees__tree_id__events_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventRead" + }, + "title": "Response List Tree Events Api V1 Trees Tree Id Events Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/api/v1/trees/{tree_id}/persons/{person_id}/events": { @@ -745,6 +996,51 @@ } } } + }, + "get": { + "tags": [ + "relationships" + ], + "summary": "List Relationships", + "operationId": "list_relationships_api_v1_trees__tree_id__relationships_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RelationshipRead" + }, + "title": "Response List Relationships Api V1 Trees Tree Id Relationships Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {