diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py
index ee7faa3..88926cc 100644
--- a/backend/app/api/v1/__init__.py
+++ b/backend/app/api/v1/__init__.py
@@ -2,10 +2,12 @@
from fastapi import APIRouter
-from app.api.v1 import auth, persons, trees, users
+from app.api.v1 import auth, events, persons, relationships, trees, users
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(trees.router)
api_router.include_router(persons.router)
+api_router.include_router(events.router)
+api_router.include_router(relationships.router)
diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py
new file mode 100644
index 0000000..90b73c6
--- /dev/null
+++ b/backend/app/api/v1/events.py
@@ -0,0 +1,39 @@
+import uuid
+
+from fastapi import APIRouter, status
+
+from app.api.deps import CurrentUser, SessionDep
+from app.schemas.event import EventCreate, EventRead
+from app.services import event_service, tree_service
+
+router = APIRouter(prefix="/trees", tags=["events"])
+
+
+@router.post("/{tree_id}/events", response_model=EventRead, status_code=status.HTTP_201_CREATED)
+async def create_event(
+ tree_id: uuid.UUID, data: EventCreate, session: SessionDep, current: CurrentUser
+) -> EventRead:
+ tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
+ event = await event_service.create_event(
+ session, actor=current, tree=tree, **data.model_dump()
+ )
+ return EventRead.model_validate(event)
+
+
+@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
+) -> list[EventRead]:
+ tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
+ events = await event_service.list_events_for_person(
+ session, viewer_id=current.id, tree=tree, person_id=person_id
+ )
+ return [EventRead.model_validate(e) for e in events]
+
+
+@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_event(
+ tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
+) -> None:
+ tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
+ await event_service.delete_event(session, actor=current, tree=tree, event_id=event_id)
diff --git a/backend/app/api/v1/persons.py b/backend/app/api/v1/persons.py
index 8624918..913d266 100644
--- a/backend/app/api/v1/persons.py
+++ b/backend/app/api/v1/persons.py
@@ -41,3 +41,14 @@ async def list_persons(
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)
return [PersonRead.model_validate(p) for p in persons]
+
+
+@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
+) -> PersonRead:
+ tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
+ person = await person_service.get_person(
+ session, viewer_id=current.id, tree=tree, person_id=person_id
+ )
+ return PersonRead.model_validate(person)
diff --git a/backend/app/api/v1/relationships.py b/backend/app/api/v1/relationships.py
new file mode 100644
index 0000000..dab0146
--- /dev/null
+++ b/backend/app/api/v1/relationships.py
@@ -0,0 +1,50 @@
+import uuid
+
+from fastapi import APIRouter, status
+
+from app.api.deps import CurrentUser, SessionDep
+from app.schemas.relationship import RelationshipCreate, RelationshipRead
+from app.services import relationship_service, tree_service
+
+router = APIRouter(prefix="/trees", tags=["relationships"])
+
+
+@router.post(
+ "/{tree_id}/relationships",
+ response_model=RelationshipRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_relationship(
+ tree_id: uuid.UUID, data: RelationshipCreate, session: SessionDep, current: CurrentUser
+) -> RelationshipRead:
+ tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
+ relationship = await relationship_service.create_relationship(
+ session, actor=current, tree=tree, **data.model_dump()
+ )
+ return RelationshipRead.model_validate(relationship)
+
+
+@router.get(
+ "/{tree_id}/persons/{person_id}/relationships",
+ response_model=list[RelationshipRead],
+)
+async def list_person_relationships(
+ tree_id: uuid.UUID, person_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_for_person(
+ session, viewer_id=current.id, tree=tree, person_id=person_id
+ )
+ return [RelationshipRead.model_validate(r) for r in rels]
+
+
+@router.delete(
+ "/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
+)
+async def delete_relationship(
+ tree_id: uuid.UUID, relationship_id: uuid.UUID, session: SessionDep, current: CurrentUser
+) -> None:
+ tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
+ await relationship_service.delete_relationship(
+ session, actor=current, tree=tree, relationship_id=relationship_id
+ )
diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py
new file mode 100644
index 0000000..9f6bd3d
--- /dev/null
+++ b/backend/app/schemas/event.py
@@ -0,0 +1,39 @@
+import uuid
+from datetime import date, datetime
+
+from pydantic import BaseModel, ConfigDict
+
+
+class EventCreate(BaseModel):
+ event_type: str
+ # Exactly one subject: a person or a partnership (relationship).
+ person_id: uuid.UUID | None = None
+ relationship_id: uuid.UUID | None = None
+ place_id: uuid.UUID | None = None
+ # Verbatim date string (e.g. "ABT 1850") and/or a normalized range.
+ date_value: str | None = None
+ date_start: date | None = None
+ date_end: date | None = None
+ date_precision: str | None = None
+ calendar: str = "gregorian"
+ detail: str | None = None
+ notes: str | None = None
+
+
+class EventRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: uuid.UUID
+ tree_id: uuid.UUID
+ event_type: str
+ person_id: uuid.UUID | None
+ relationship_id: uuid.UUID | None
+ place_id: uuid.UUID | None
+ date_value: str | None
+ date_start: date | None
+ date_end: date | None
+ date_precision: str | None
+ calendar: str
+ detail: str | None
+ notes: str | None
+ created_at: datetime
diff --git a/backend/app/schemas/relationship.py b/backend/app/schemas/relationship.py
new file mode 100644
index 0000000..880cda4
--- /dev/null
+++ b/backend/app/schemas/relationship.py
@@ -0,0 +1,28 @@
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
+
+from app.models.enums import ParentChildQualifier, RelationshipType
+
+
+class RelationshipCreate(BaseModel):
+ type: RelationshipType
+ person_from_id: uuid.UUID
+ person_to_id: uuid.UUID
+ # Only meaningful for parent_child edges (from = parent, to = child).
+ qualifier: ParentChildQualifier | None = None
+ notes: str | None = None
+
+
+class RelationshipRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: uuid.UUID
+ tree_id: uuid.UUID
+ type: RelationshipType
+ person_from_id: uuid.UUID
+ person_to_id: uuid.UUID
+ qualifier: ParentChildQualifier | None
+ notes: str | None
+ created_at: datetime
diff --git a/backend/app/services/event_service.py b/backend/app/services/event_service.py
new file mode 100644
index 0000000..dbfdb3a
--- /dev/null
+++ b/backend/app/services/event_service.py
@@ -0,0 +1,136 @@
+"""Event service. Writes require editor rights; reads go through the privacy
+engine. Every event has exactly one subject — a Person or a partnership."""
+
+import uuid
+from datetime import date
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.event import Event
+from app.models.person import Person
+from app.models.place import Place
+from app.models.relationship import Relationship
+from app.models.tree import Tree
+from app.models.user import User
+from app.services import privacy
+from app.services.audit import record_audit
+from app.services.exceptions import Conflict, Forbidden, NotFound
+
+
+async def _belongs_to_tree(
+ session: AsyncSession, model: type, id_: uuid.UUID, tree_id: uuid.UUID
+) -> bool:
+ row = (
+ await session.execute(
+ select(model.id).where(
+ model.id == id_, model.tree_id == tree_id, model.deleted_at.is_(None)
+ )
+ )
+ ).scalar_one_or_none()
+ return row is not None
+
+
+async def create_event(
+ session: AsyncSession,
+ *,
+ actor: User,
+ tree: Tree,
+ event_type: str,
+ person_id: uuid.UUID | None = None,
+ relationship_id: uuid.UUID | None = None,
+ place_id: uuid.UUID | None = None,
+ date_value: str | None = None,
+ date_start: date | None = None,
+ date_end: date | None = None,
+ date_precision: str | None = None,
+ calendar: str = "gregorian",
+ detail: str | None = None,
+ notes: str | None = None,
+) -> Event:
+ if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
+ raise Forbidden("not an editor of this tree")
+ if bool(person_id) == bool(relationship_id):
+ raise Conflict("an event needs exactly one subject: person_id or relationship_id")
+ if person_id and not await _belongs_to_tree(session, Person, person_id, tree.id):
+ raise NotFound("person not found in this tree")
+ if relationship_id and not await _belongs_to_tree(
+ session, Relationship, relationship_id, tree.id
+ ):
+ raise NotFound("relationship not found in this tree")
+ if place_id and not await _belongs_to_tree(session, Place, place_id, tree.id):
+ raise NotFound("place not found in this tree")
+
+ event = Event(
+ tree_id=tree.id,
+ event_type=event_type,
+ person_id=person_id,
+ relationship_id=relationship_id,
+ place_id=place_id,
+ date_value=date_value,
+ date_start=date_start,
+ date_end=date_end,
+ date_precision=date_precision,
+ calendar=calendar,
+ detail=detail,
+ notes=notes,
+ )
+ session.add(event)
+ await session.flush()
+ record_audit(
+ session,
+ action="create",
+ entity_type="Event",
+ entity_id=event.id,
+ tree_id=tree.id,
+ actor_user_id=actor.id,
+ after={"event_type": event_type, "person_id": str(person_id) if person_id else None},
+ )
+ await session.commit()
+ await session.refresh(event)
+ return event
+
+
+async def list_events_for_person(
+ session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
+) -> list[Event]:
+ 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.person_id == person_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 delete_event(
+ session: AsyncSession, *, actor: User, tree: Tree, event_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")
+ event = (
+ await session.execute(
+ select(Event).where(
+ Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
+ )
+ )
+ ).scalar_one_or_none()
+ if event is None:
+ raise NotFound("event not found")
+ from datetime import UTC, datetime
+
+ event.deleted_at = datetime.now(UTC)
+ record_audit(
+ session,
+ action="delete",
+ entity_type="Event",
+ entity_id=event.id,
+ tree_id=tree.id,
+ actor_user_id=actor.id,
+ )
+ await session.commit()
diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py
index 320deb1..6338f71 100644
--- a/backend/app/services/person_service.py
+++ b/backend/app/services/person_service.py
@@ -14,7 +14,7 @@ from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
-from app.services.exceptions import Forbidden
+from app.services.exceptions import Forbidden, NotFound
from app.services.privacy import Visibility
@@ -86,6 +86,32 @@ async def create_person(
return person
+async def get_person(
+ session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
+) -> Person:
+ if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
+ raise Forbidden("not permitted to view 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")
+ # Run the single person through the privacy engine (redaction lands Phase 2).
+ if (
+ await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
+ == Visibility.hidden
+ ):
+ raise NotFound("person not found")
+ await _attach_primary_name(session, person)
+ return person
+
+
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
new file mode 100644
index 0000000..129a39a
--- /dev/null
+++ b/backend/app/services/relationship_service.py
@@ -0,0 +1,121 @@
+"""Relationship service. Typed, qualified edges between two Persons in a tree.
+Writes require editor rights; reads go through the privacy engine."""
+
+import uuid
+from datetime import UTC, datetime
+
+from sqlalchemy import or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.enums import ParentChildQualifier, RelationshipType
+from app.models.person import Person
+from app.models.relationship import Relationship
+from app.models.tree import Tree
+from app.models.user import User
+from app.services import privacy
+from app.services.audit import record_audit
+from app.services.exceptions import Conflict, Forbidden, NotFound
+
+
+async def _person_in_tree(session: AsyncSession, person_id: uuid.UUID, tree_id: uuid.UUID) -> bool:
+ row = (
+ await session.execute(
+ select(Person.id).where(
+ Person.id == person_id, Person.tree_id == tree_id, Person.deleted_at.is_(None)
+ )
+ )
+ ).scalar_one_or_none()
+ return row is not None
+
+
+async def create_relationship(
+ session: AsyncSession,
+ *,
+ actor: User,
+ tree: Tree,
+ type: RelationshipType,
+ person_from_id: uuid.UUID,
+ person_to_id: uuid.UUID,
+ qualifier: ParentChildQualifier | None = None,
+ notes: str | None = None,
+) -> Relationship:
+ if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
+ raise Forbidden("not an editor of this tree")
+ if person_from_id == person_to_id:
+ raise Conflict("a relationship needs two different people")
+ if qualifier is not None and type is not RelationshipType.parent_child:
+ raise Conflict("qualifier only applies to parent_child relationships")
+ for pid in (person_from_id, person_to_id):
+ if not await _person_in_tree(session, pid, tree.id):
+ raise NotFound("person not found in this tree")
+
+ relationship = Relationship(
+ tree_id=tree.id,
+ type=type,
+ person_from_id=person_from_id,
+ person_to_id=person_to_id,
+ qualifier=qualifier,
+ notes=notes,
+ )
+ session.add(relationship)
+ await session.flush()
+ record_audit(
+ session,
+ action="create",
+ entity_type="Relationship",
+ entity_id=relationship.id,
+ tree_id=tree.id,
+ actor_user_id=actor.id,
+ after={"type": type.value, "from": str(person_from_id), "to": str(person_to_id)},
+ )
+ await session.commit()
+ await session.refresh(relationship)
+ return relationship
+
+
+async def list_relationships_for_person(
+ session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
+) -> list[Relationship]:
+ 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),
+ or_(
+ Relationship.person_from_id == person_id,
+ Relationship.person_to_id == person_id,
+ ),
+ )
+ .order_by(Relationship.created_at)
+ )
+ return list((await session.execute(stmt)).scalars().all())
+
+
+async def delete_relationship(
+ session: AsyncSession, *, actor: User, tree: Tree, relationship_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")
+ relationship = (
+ await session.execute(
+ select(Relationship).where(
+ Relationship.id == relationship_id,
+ Relationship.tree_id == tree.id,
+ Relationship.deleted_at.is_(None),
+ )
+ )
+ ).scalar_one_or_none()
+ if relationship is None:
+ raise NotFound("relationship not found")
+ relationship.deleted_at = datetime.now(UTC)
+ record_audit(
+ session,
+ action="delete",
+ entity_type="Relationship",
+ entity_id=relationship.id,
+ tree_id=tree.id,
+ actor_user_id=actor.id,
+ )
+ await session.commit()
diff --git a/backend/tests/test_graph.py b/backend/tests/test_graph.py
new file mode 100644
index 0000000..9af83a0
--- /dev/null
+++ b/backend/tests/test_graph.py
@@ -0,0 +1,116 @@
+"""Events and relationships through the API."""
+
+from tests.conftest import auth, register
+
+
+async def _setup_tree_with_two_people(client, email: str):
+ token = await register(client, email)
+ h = auth(token)
+ tree_id = (
+ await client.post("/api/v1/trees", json={"name": "Graph"}, headers=h)
+ ).json()["id"]
+ parent = (
+ await client.post(
+ f"/api/v1/trees/{tree_id}/persons",
+ json={"given": "Anna", "surname": "Vogel"},
+ headers=h,
+ )
+ ).json()["id"]
+ child = (
+ await client.post(
+ f"/api/v1/trees/{tree_id}/persons",
+ json={"given": "Beth", "surname": "Vogel"},
+ headers=h,
+ )
+ ).json()["id"]
+ return h, tree_id, parent, child
+
+
+async def test_event_create_list_delete(client):
+ h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "ev1@example.com")
+
+ resp = await client.post(
+ f"/api/v1/trees/{tree_id}/events",
+ json={"event_type": "birth", "person_id": parent, "date_value": "ABT 1850"},
+ headers=h,
+ )
+ assert resp.status_code == 201, resp.text
+ event_id = resp.json()["id"]
+
+ listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h)
+ assert listed.status_code == 200
+ assert len(listed.json()) == 1
+ assert listed.json()[0]["event_type"] == "birth"
+
+ resp = await client.delete(f"/api/v1/trees/{tree_id}/events/{event_id}", headers=h)
+ assert resp.status_code == 204
+ listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h)
+ assert len(listed.json()) == 0
+
+
+async def test_event_requires_exactly_one_subject(client):
+ h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
+ resp = await client.post(
+ f"/api/v1/trees/{tree_id}/events", json={"event_type": "birth"}, headers=h
+ )
+ assert resp.status_code == 409
+
+
+async def test_relationship_create_and_list(client):
+ h, tree_id, parent, child = await _setup_tree_with_two_people(client, "rel1@example.com")
+
+ resp = await client.post(
+ f"/api/v1/trees/{tree_id}/relationships",
+ json={
+ "type": "parent_child",
+ "person_from_id": parent,
+ "person_to_id": child,
+ "qualifier": "biological",
+ },
+ headers=h,
+ )
+ assert resp.status_code == 201, resp.text
+
+ for pid in (parent, child):
+ listed = await client.get(
+ f"/api/v1/trees/{tree_id}/persons/{pid}/relationships", headers=h
+ )
+ assert listed.status_code == 200
+ assert len(listed.json()) == 1
+ assert listed.json()[0]["qualifier"] == "biological"
+
+
+async def test_relationship_validation(client):
+ h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel2@example.com")
+ # Same person on both ends.
+ resp = await client.post(
+ f"/api/v1/trees/{tree_id}/relationships",
+ json={"type": "sibling", "person_from_id": parent, "person_to_id": parent},
+ headers=h,
+ )
+ assert resp.status_code == 409
+
+ # Qualifier on a non-parent_child edge.
+ h2, t2, p_a, p_b = await _setup_tree_with_two_people(client, "rel3@example.com")
+ resp = await client.post(
+ f"/api/v1/trees/{t2}/relationships",
+ json={
+ "type": "partnership",
+ "person_from_id": p_a,
+ "person_to_id": p_b,
+ "qualifier": "biological",
+ },
+ headers=h2,
+ )
+ assert resp.status_code == 409
+
+
+async def test_non_member_cannot_write_graph(client):
+ h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel4@example.com")
+ other = auth(await register(client, "intruder@example.com"))
+ resp = await client.post(
+ f"/api/v1/trees/{tree_id}/events",
+ json={"event_type": "birth", "person_id": parent},
+ headers=other,
+ )
+ assert resp.status_code == 403
diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx
index cf3f2db..b7907d7 100644
--- a/frontend/app/trees/[id]/page.tsx
+++ b/frontend/app/trees/[id]/page.tsx
@@ -81,11 +81,15 @@ export default function TreeDetailPage() {
{persons.map((person) => (
-
-
-
- {person.primary_name ?? Unnamed}
-
-
+
+
+
+ {person.primary_name ?? (
+ Unnamed
+ )}
+
+
+
))}
diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx
new file mode 100644
index 0000000..5a0729e
--- /dev/null
+++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx
@@ -0,0 +1,280 @@
+"use client";
+
+import Link from "next/link";
+import { useParams, useRouter } from "next/navigation";
+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 { Input } from "@/components/ui/input";
+
+type Person = components["schemas"]["PersonRead"];
+type Event = components["schemas"]["EventRead"];
+type Relationship = components["schemas"]["RelationshipRead"];
+type Qualifier = components["schemas"]["ParentChildQualifier"];
+type RelCreate = components["schemas"]["RelationshipCreate"];
+
+const fieldCls =
+ "h-10 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
+
+const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
+
+export default function PersonDetailPage() {
+ const router = useRouter();
+ const params = useParams<{ id: string; personId: string }>();
+ const treeId = params.id;
+ const personId = params.personId;
+
+ const [person, setPerson] = useState(null);
+ const [people, setPeople] = useState([]);
+ const [events, setEvents] = useState([]);
+ const [rels, setRels] = useState([]);
+ const [ready, setReady] = useState(false);
+
+ const [evType, setEvType] = useState("birth");
+ const [evDate, setEvDate] = useState("");
+
+ const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
+ const [relOther, setRelOther] = useState("");
+ const [relQual, setRelQual] = useState("biological");
+
+ const load = useCallback(async () => {
+ const p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", {
+ params: { path: { tree_id: treeId, person_id: personId } },
+ });
+ if (p.response.status === 401) {
+ router.push("/login");
+ return;
+ }
+ setPerson(p.data ?? null);
+ const [all, ev, rl] = await Promise.all([
+ api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
+ api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
+ params: { path: { tree_id: treeId, person_id: personId } },
+ }),
+ api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/relationships", {
+ params: { path: { tree_id: treeId, person_id: personId } },
+ }),
+ ]);
+ setPeople(all.data ?? []);
+ setEvents(ev.data ?? []);
+ setRels(rl.data ?? []);
+ setReady(true);
+ }, [router, treeId, personId]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const nameOf = useMemo(() => {
+ const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"]));
+ return (id: string) => m.get(id) ?? "Unknown";
+ }, [people]);
+
+ 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");
+ const siblings = rels.filter((r) => r.type === "sibling");
+
+ async function addEvent(e: React.FormEvent) {
+ e.preventDefault();
+ if (!evType.trim()) return;
+ const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
+ params: { path: { tree_id: treeId } },
+ body: { event_type: evType, person_id: personId, date_value: evDate || null },
+ });
+ if (!error) {
+ setEvDate("");
+ load();
+ }
+ }
+
+ async function removeEvent(id: string) {
+ await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", {
+ params: { path: { tree_id: treeId, event_id: id } },
+ });
+ load();
+ }
+
+ async function addRel(e: React.FormEvent) {
+ e.preventDefault();
+ if (!relOther) return;
+ let body: RelCreate;
+ if (relKind === "parent") {
+ body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual };
+ } else if (relKind === "child") {
+ body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual };
+ } else if (relKind === "partner") {
+ body = { type: "partnership", person_from_id: personId, person_to_id: relOther };
+ } else {
+ body = { type: "sibling", person_from_id: personId, person_to_id: relOther };
+ }
+ const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
+ params: { path: { tree_id: treeId } },
+ body,
+ });
+ if (!error) {
+ setRelOther("");
+ load();
+ }
+ }
+
+ async function removeRel(id: string) {
+ await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
+ params: { path: { tree_id: treeId, relationship_id: id } },
+ });
+ load();
+ }
+
+ if (!ready) return Loading…
;
+ if (!person) return Not found.
;
+
+ const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) =>
+ items.length > 0 && (
+
+
{label}
+
+ {items.map((r) => (
+ -
+
+ {nameOf(otherId(r))}
+ {r.qualifier ? · {r.qualifier} : null}
+
+
+
+ ))}
+
+
+ );
+
+ return (
+
+
+ ← Back to tree
+
+
+
{person.primary_name ?? "Unnamed person"}
+
+
+
+ Life events
+
+
+ {events.length === 0 ? (
+ No events yet.
+ ) : (
+
+ {events.map((ev) => (
+ -
+
+ {ev.event_type}
+ {ev.date_value ? (
+ — {ev.date_value}
+ ) : null}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Relationships
+
+
+ {rels.length === 0 ? (
+ No relationships yet.
+ ) : (
+
+ {relGroup("Parents", parents, (r) => r.person_from_id)}
+ {relGroup("Children", children, (r) => r.person_to_id)}
+ {relGroup("Partners", partners, (r) =>
+ r.person_from_id === personId ? r.person_to_id : r.person_from_id,
+ )}
+ {relGroup("Siblings", siblings, (r) =>
+ r.person_from_id === personId ? r.person_to_id : r.person_from_id,
+ )}
+
+ )}
+
+ {others.length === 0 ? (
+ Add more people to the tree to link them.
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts
index fde55b1..ac8b28c 100644
--- a/frontend/lib/api/schema.d.ts
+++ b/frontend/lib/api/schema.d.ts
@@ -210,10 +210,197 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/trees/{tree_id}/persons/{person_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get Person */
+ get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/trees/{tree_id}/events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Create Event */
+ post: operations["create_event_api_v1_trees__tree_id__events_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/trees/{tree_id}/persons/{person_id}/events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List Person Events */
+ get: operations["list_person_events_api_v1_trees__tree_id__persons__person_id__events_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/trees/{tree_id}/events/{event_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** Delete Event */
+ delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/trees/{tree_id}/relationships": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Create Relationship */
+ post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List Person Relationships */
+ get: operations["list_person_relationships_api_v1_trees__tree_id__persons__person_id__relationships_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/trees/{tree_id}/relationships/{relationship_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** Delete Relationship */
+ delete: operations["delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record;
export interface components {
schemas: {
+ /** EventCreate */
+ EventCreate: {
+ /** Event Type */
+ event_type: string;
+ /** Person Id */
+ person_id?: string | null;
+ /** Relationship Id */
+ relationship_id?: string | null;
+ /** Place Id */
+ place_id?: string | null;
+ /** Date Value */
+ date_value?: string | null;
+ /** Date Start */
+ date_start?: string | null;
+ /** Date End */
+ date_end?: string | null;
+ /** Date Precision */
+ date_precision?: string | null;
+ /**
+ * Calendar
+ * @default gregorian
+ */
+ calendar?: string;
+ /** Detail */
+ detail?: string | null;
+ /** Notes */
+ notes?: string | null;
+ };
+ /** EventRead */
+ EventRead: {
+ /**
+ * Id
+ * Format: uuid
+ */
+ id: string;
+ /**
+ * Tree Id
+ * Format: uuid
+ */
+ tree_id: string;
+ /** Event Type */
+ event_type: string;
+ /** Person Id */
+ person_id: string | null;
+ /** Relationship Id */
+ relationship_id: string | null;
+ /** Place Id */
+ place_id: string | null;
+ /** Date Value */
+ date_value: string | null;
+ /** Date Start */
+ date_start: string | null;
+ /** Date End */
+ date_end: string | null;
+ /** Date Precision */
+ date_precision: string | null;
+ /** Calendar */
+ calendar: string;
+ /** Detail */
+ detail: string | null;
+ /** Notes */
+ notes: string | null;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ };
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
@@ -226,6 +413,13 @@ export interface components {
/** Password */
password: string;
};
+ /**
+ * ParentChildQualifier
+ * @description Qualifies a parent_child edge so adoption/donor/blended families are
+ * first-class rather than edge cases (ARCHITECTURE §5).
+ * @enum {string}
+ */
+ ParentChildQualifier: "biological" | "adoptive" | "step" | "foster" | "donor" | "guardian";
/** PasswordResetConfirm */
PasswordResetConfirm: {
/** Token */
@@ -293,6 +487,60 @@ export interface components {
/** Display Name */
display_name?: string | null;
};
+ /** RelationshipCreate */
+ RelationshipCreate: {
+ type: components["schemas"]["RelationshipType"];
+ /**
+ * Person From Id
+ * Format: uuid
+ */
+ person_from_id: string;
+ /**
+ * Person To Id
+ * Format: uuid
+ */
+ person_to_id: string;
+ qualifier?: components["schemas"]["ParentChildQualifier"] | null;
+ /** Notes */
+ notes?: string | null;
+ };
+ /** RelationshipRead */
+ RelationshipRead: {
+ /**
+ * Id
+ * Format: uuid
+ */
+ id: string;
+ /**
+ * Tree Id
+ * Format: uuid
+ */
+ tree_id: string;
+ type: components["schemas"]["RelationshipType"];
+ /**
+ * Person From Id
+ * Format: uuid
+ */
+ person_from_id: string;
+ /**
+ * Person To Id
+ * Format: uuid
+ */
+ person_to_id: string;
+ qualifier: components["schemas"]["ParentChildQualifier"] | null;
+ /** Notes */
+ notes: string | null;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ };
+ /**
+ * RelationshipType
+ * @enum {string}
+ */
+ RelationshipType: "parent_child" | "partnership" | "sibling";
/** SessionRead */
SessionRead: {
user: components["schemas"]["UserRead"];
@@ -782,4 +1030,230 @@ export interface operations {
};
};
};
+ get_person_api_v1_trees__tree_id__persons__person_id__get: {
+ 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"];
+ };
+ };
+ };
+ };
+ create_event_api_v1_trees__tree_id__events_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ tree_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["EventCreate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["EventRead"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ list_person_events_api_v1_trees__tree_id__persons__person_id__events_get: {
+ 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"]["EventRead"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_event_api_v1_trees__tree_id__events__event_id__delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ tree_id: string;
+ event_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"];
+ };
+ };
+ };
+ };
+ create_relationship_api_v1_trees__tree_id__relationships_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ tree_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["RelationshipCreate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RelationshipRead"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ list_person_relationships_api_v1_trees__tree_id__persons__person_id__relationships_get: {
+ 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"]["RelationshipRead"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ tree_id: string;
+ relationship_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"];
+ };
+ };
+ };
+ };
}
diff --git a/frontend/openapi.json b/frontend/openapi.json
index 1857ff9..cdd5477 100644
--- a/frontend/openapi.json
+++ b/frontend/openapi.json
@@ -484,10 +484,646 @@
}
}
}
+ },
+ "/api/v1/trees/{tree_id}/persons/{person_id}": {
+ "get": {
+ "tags": [
+ "persons"
+ ],
+ "summary": "Get Person",
+ "operationId": "get_person_api_v1_trees__tree_id__persons__person_id__get",
+ "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": [
+ "events"
+ ],
+ "summary": "Create Event",
+ "operationId": "create_event_api_v1_trees__tree_id__events_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/EventCreate"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EventRead"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/trees/{tree_id}/persons/{person_id}/events": {
+ "get": {
+ "tags": [
+ "events"
+ ],
+ "summary": "List Person Events",
+ "operationId": "list_person_events_api_v1_trees__tree_id__persons__person_id__events_get",
+ "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": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EventRead"
+ },
+ "title": "Response List Person Events Api V1 Trees Tree Id Persons Person Id Events Get"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/trees/{tree_id}/events/{event_id}": {
+ "delete": {
+ "tags": [
+ "events"
+ ],
+ "summary": "Delete Event",
+ "operationId": "delete_event_api_v1_trees__tree_id__events__event_id__delete",
+ "parameters": [
+ {
+ "name": "tree_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Tree Id"
+ }
+ },
+ {
+ "name": "event_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Event Id"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Successful Response"
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/trees/{tree_id}/relationships": {
+ "post": {
+ "tags": [
+ "relationships"
+ ],
+ "summary": "Create Relationship",
+ "operationId": "create_relationship_api_v1_trees__tree_id__relationships_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/RelationshipCreate"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RelationshipRead"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
+ "get": {
+ "tags": [
+ "relationships"
+ ],
+ "summary": "List Person Relationships",
+ "operationId": "list_person_relationships_api_v1_trees__tree_id__persons__person_id__relationships_get",
+ "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": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RelationshipRead"
+ },
+ "title": "Response List Person Relationships Api V1 Trees Tree Id Persons Person Id Relationships Get"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/trees/{tree_id}/relationships/{relationship_id}": {
+ "delete": {
+ "tags": [
+ "relationships"
+ ],
+ "summary": "Delete Relationship",
+ "operationId": "delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete",
+ "parameters": [
+ {
+ "name": "tree_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Tree Id"
+ }
+ },
+ {
+ "name": "relationship_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Relationship Id"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Successful Response"
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"components": {
"schemas": {
+ "EventCreate": {
+ "properties": {
+ "event_type": {
+ "type": "string",
+ "title": "Event Type"
+ },
+ "person_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Person Id"
+ },
+ "relationship_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Relationship Id"
+ },
+ "place_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Place Id"
+ },
+ "date_value": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date Value"
+ },
+ "date_start": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date Start"
+ },
+ "date_end": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date End"
+ },
+ "date_precision": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date Precision"
+ },
+ "calendar": {
+ "type": "string",
+ "title": "Calendar",
+ "default": "gregorian"
+ },
+ "detail": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Detail"
+ },
+ "notes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Notes"
+ }
+ },
+ "type": "object",
+ "required": [
+ "event_type"
+ ],
+ "title": "EventCreate"
+ },
+ "EventRead": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Id"
+ },
+ "tree_id": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Tree Id"
+ },
+ "event_type": {
+ "type": "string",
+ "title": "Event Type"
+ },
+ "person_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Person Id"
+ },
+ "relationship_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Relationship Id"
+ },
+ "place_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Place Id"
+ },
+ "date_value": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date Value"
+ },
+ "date_start": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date Start"
+ },
+ "date_end": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date End"
+ },
+ "date_precision": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Date Precision"
+ },
+ "calendar": {
+ "type": "string",
+ "title": "Calendar"
+ },
+ "detail": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Detail"
+ },
+ "notes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Notes"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Created At"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "tree_id",
+ "event_type",
+ "person_id",
+ "relationship_id",
+ "place_id",
+ "date_value",
+ "date_start",
+ "date_end",
+ "date_precision",
+ "calendar",
+ "detail",
+ "notes",
+ "created_at"
+ ],
+ "title": "EventRead"
+ },
"HTTPValidationError": {
"properties": {
"detail": {
@@ -519,6 +1155,19 @@
],
"title": "LoginRequest"
},
+ "ParentChildQualifier": {
+ "type": "string",
+ "enum": [
+ "biological",
+ "adoptive",
+ "step",
+ "foster",
+ "donor",
+ "guardian"
+ ],
+ "title": "ParentChildQualifier",
+ "description": "Qualifies a parent_child edge so adoption/donor/blended families are\nfirst-class rather than edge cases (ARCHITECTURE \u00a75)."
+ },
"PasswordResetConfirm": {
"properties": {
"token": {
@@ -721,6 +1370,125 @@
],
"title": "RegisterRequest"
},
+ "RelationshipCreate": {
+ "properties": {
+ "type": {
+ "$ref": "#/components/schemas/RelationshipType"
+ },
+ "person_from_id": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Person From Id"
+ },
+ "person_to_id": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Person To Id"
+ },
+ "qualifier": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ParentChildQualifier"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "notes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Notes"
+ }
+ },
+ "type": "object",
+ "required": [
+ "type",
+ "person_from_id",
+ "person_to_id"
+ ],
+ "title": "RelationshipCreate"
+ },
+ "RelationshipRead": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Id"
+ },
+ "tree_id": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Tree Id"
+ },
+ "type": {
+ "$ref": "#/components/schemas/RelationshipType"
+ },
+ "person_from_id": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Person From Id"
+ },
+ "person_to_id": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Person To Id"
+ },
+ "qualifier": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ParentChildQualifier"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "notes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Notes"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Created At"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "tree_id",
+ "type",
+ "person_from_id",
+ "person_to_id",
+ "qualifier",
+ "notes",
+ "created_at"
+ ],
+ "title": "RelationshipRead"
+ },
+ "RelationshipType": {
+ "type": "string",
+ "enum": [
+ "parent_child",
+ "partnership",
+ "sibling"
+ ],
+ "title": "RelationshipType"
+ },
"SessionRead": {
"properties": {
"user": {