From d6e2df4a616ad682a9a1a4f5c5e0058a014661c7 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 12:10:56 -0400 Subject: [PATCH 1/2] Add events and relationships API (Phase 1: flesh out the graph) Events (create/list-per-person/soft-delete) and relationships (create/list-per-person/soft-delete) through the layered stack: editor-gated writes, privacy-engine reads, audit on every change. Events carry exactly one subject (person XOR partnership); relationships are typed qualified edges (parent_child gets a biological/adoptive/step/foster/donor/guardian qualifier). Adds a single-person GET. 18 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/v1/__init__.py | 4 +- backend/app/api/v1/events.py | 39 ++++++ backend/app/api/v1/persons.py | 11 ++ backend/app/api/v1/relationships.py | 50 +++++++ backend/app/schemas/event.py | 39 ++++++ backend/app/schemas/relationship.py | 28 ++++ backend/app/services/event_service.py | 136 +++++++++++++++++++ backend/app/services/person_service.py | 28 +++- backend/app/services/relationship_service.py | 121 +++++++++++++++++ backend/tests/test_graph.py | 116 ++++++++++++++++ 10 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/v1/events.py create mode 100644 backend/app/api/v1/relationships.py create mode 100644 backend/app/schemas/event.py create mode 100644 backend/app/schemas/relationship.py create mode 100644 backend/app/services/event_service.py create mode 100644 backend/app/services/relationship_service.py create mode 100644 backend/tests/test_graph.py 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 -- 2.52.0 From 1f25eb2f21d32975d150f6f5fd4da84e5750c41a Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 12:10:56 -0400 Subject: [PATCH 2/2] Add person-detail page with events timeline and relationships New /trees/[id]/persons/[personId] view: life-events timeline with add/remove, and relationships grouped into parents/children/partners/siblings with an add form (kind + person picker + qualifier). People in the tree list now link here. Regenerated the OpenAPI client for the new endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- frontend/app/trees/[id]/page.tsx | 14 +- .../trees/[id]/persons/[personId]/page.tsx | 280 +++++++ frontend/lib/api/schema.d.ts | 474 +++++++++++ frontend/openapi.json | 768 ++++++++++++++++++ 4 files changed, 1531 insertions(+), 5 deletions(-) create mode 100644 frontend/app/trees/[id]/persons/[personId]/page.tsx 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} + + +
  • + ))} +
+ )} +
+ setEvType(e.target.value)} + /> + setEvDate(e.target.value)} + /> + +
+
+
+ + + + 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.

+ ) : ( +
+ Add + + + {(relKind === "parent" || relKind === "child") && ( + + )} + +
+ )} +
+
+
+ ); +} 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": { -- 2.52.0