8 Commits

Author SHA1 Message Date
justin 83f83ab641 Add source manager and inline citing with 'sourced' badges
New /trees/[id]/sources page (list + create sources). Person-detail page now loads tree sources + citations and shows a '✓ N sourced' badge with an inline cite picker (source + page) on each event and on the person. Tree view links to Sources. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin 064bb6ea65 Add sources and citations API (Phase 1: sources-first spine)
Source CRUD (reusable, tree-scoped) and Citation create/list/soft-delete linking one source to exactly one fact (person/event/name/relationship). Editor-gated writes, privacy-filtered reads, audit throughout; tenant + existence validation on source and target. list_citations returns all tree citations so the UI can render 'sourced' indicators in one round-trip. 22 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin fbb9d0195c Merge pull request 'Phase 1: events + relationships + person detail' (#5) from phase1-graph into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m16s
2026-06-06 12:11:11 -04:00
justin 1f25eb2f21 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) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 12:10:56 -04:00
justin d6e2df4a61 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) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 12:10:56 -04:00
justin a799d101b5 Merge pull request 'Use host global Watchtower (drop bundled one)' (#4) from watchtower-use-host into main 2026-06-06 11:58:50 -04:00
justin 0b9d72c878 Drop bundled Watchtower; rely on the host's global Watchtower
ripper already runs a single global nickfedor/watchtower (label-enabled) that watches every stack; the bundled containrrr/watchtower was redundant and crash-looped (its Docker API client is too old for Docker 29). Keep the watchtower.enable labels on backend/frontend so the host instance auto-deploys them; remove the per-stack service and profile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:58:49 -04:00
justin 2d0635e710 Merge pull request 'Add Watchtower auto-deploy (2-min poll)' (#3) from watchtower-autodeploy into main 2026-06-06 11:55:51 -04:00
23 changed files with 4025 additions and 27 deletions
+14 -1
View File
@@ -2,10 +2,23 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, persons, trees, users from app.api.v1 import (
auth,
citations,
events,
persons,
relationships,
sources,
trees,
users,
)
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(trees.router) api_router.include_router(trees.router)
api_router.include_router(persons.router) api_router.include_router(persons.router)
api_router.include_router(events.router)
api_router.include_router(relationships.router)
api_router.include_router(sources.router)
api_router.include_router(citations.router)
+41
View File
@@ -0,0 +1,41 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead
from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"])
@router.post(
"/{tree_id}/citations", response_model=CitationRead, status_code=status.HTTP_201_CREATED
)
async def create_citation(
tree_id: uuid.UUID, data: CitationCreate, session: SessionDep, current: CurrentUser
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.create_citation(
session, actor=current, tree=tree, **data.model_dump()
)
return CitationRead.model_validate(citation)
@router.get("/{tree_id}/citations", response_model=list[CitationRead])
async def list_citations(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[CitationRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citations = await citation_service.list_citations(session, viewer_id=current.id, tree=tree)
return [CitationRead.model_validate(c) for c in citations]
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await citation_service.delete_citation(
session, actor=current, tree=tree, citation_id=citation_id
)
+39
View File
@@ -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)
+11
View File
@@ -41,3 +41,14 @@ async def list_persons(
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) 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) persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
return [PersonRead.model_validate(p) for p in persons] 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)
+50
View File
@@ -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
)
+48
View File
@@ -0,0 +1,48 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead
from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"])
@router.post("/{tree_id}/sources", response_model=SourceRead, status_code=status.HTTP_201_CREATED)
async def create_source(
tree_id: uuid.UUID, data: SourceCreate, session: SessionDep, current: CurrentUser
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.create_source(
session, actor=current, tree=tree, **data.model_dump()
)
return SourceRead.model_validate(source)
@router.get("/{tree_id}/sources", response_model=list[SourceRead])
async def list_sources(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[SourceRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
sources = await source_service.list_sources(session, viewer_id=current.id, tree=tree)
return [SourceRead.model_validate(s) for s in sources]
@router.get("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def get_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.get_source(
session, viewer_id=current.id, tree=tree, source_id=source_id
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await source_service.delete_source(session, actor=current, tree=tree, source_id=source_id)
+39
View File
@@ -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
+28
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import CitationConfidence
class SourceCreate(BaseModel):
title: str
author: str | None = None
source_type: str | None = None
repository: str | None = None
url: str | None = None
citation_text: str | None = None
publication_info: str | None = None
quality_note: str | None = None
class SourceRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
title: str
author: str | None
source_type: str | None
repository: str | None
url: str | None
citation_text: str | None
publication_info: str | None
quality_note: str | None
created_at: datetime
class CitationCreate(BaseModel):
source_id: uuid.UUID
# Exactly one target fact.
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
name_id: uuid.UUID | None = None
relationship_id: uuid.UUID | None = None
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
source_id: uuid.UUID
person_id: uuid.UUID | None
event_id: uuid.UUID | None
name_id: uuid.UUID | None
relationship_id: uuid.UUID | None
page: str | None
detail: str | None
confidence: CitationConfidence | None
created_at: datetime
+141
View File
@@ -0,0 +1,141 @@
"""Citation service. A citation links one Source to exactly one fact (person,
event, name, or relationship) within a tree — the provenance spine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import CitationConfidence
from app.models.event import Event
from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Conflict, Forbidden, NotFound
# Citation target column -> model, for tenant/existence validation.
_TARGET_MODELS = {
"person_id": Person,
"event_id": Event,
"name_id": Name,
"relationship_id": Relationship,
}
async def _in_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_citation(
session: AsyncSession,
*,
actor: User,
tree: Tree,
source_id: uuid.UUID,
person_id: uuid.UUID | None = None,
event_id: uuid.UUID | None = None,
name_id: uuid.UUID | None = None,
relationship_id: uuid.UUID | None = None,
page: str | None = None,
detail: str | None = None,
confidence: CitationConfidence | None = None,
) -> Citation:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
targets = {
"person_id": person_id,
"event_id": event_id,
"name_id": name_id,
"relationship_id": relationship_id,
}
set_targets = {k: v for k, v in targets.items() if v is not None}
if len(set_targets) != 1:
raise Conflict("a citation must reference exactly one fact")
if not await _in_tree(session, Source, source_id, tree.id):
raise NotFound("source not found in this tree")
(target_col, target_id), = set_targets.items()
if not await _in_tree(session, _TARGET_MODELS[target_col], target_id, tree.id):
raise NotFound("cited fact not found in this tree")
citation = Citation(
tree_id=tree.id,
source_id=source_id,
person_id=person_id,
event_id=event_id,
name_id=name_id,
relationship_id=relationship_id,
page=page,
detail=detail,
confidence=confidence,
)
session.add(citation)
await session.flush()
record_audit(
session,
action="create",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"source_id": str(source_id), target_col: str(target_id)},
)
await session.commit()
await session.refresh(citation)
return citation
async def list_citations(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Citation]:
"""All citations in the tree — the UI maps them to facts to show 'sourced'
indicators in a single round-trip."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Citation)
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
.order_by(Citation.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_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")
citation = (
await session.execute(
select(Citation).where(
Citation.id == citation_id,
Citation.tree_id == tree.id,
Citation.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if citation is None:
raise NotFound("citation not found")
citation.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+136
View File
@@ -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()
+27 -1
View File
@@ -14,7 +14,7 @@ from app.models.tree import Tree
from app.models.user import User from app.models.user import User
from app.services import privacy from app.services import privacy
from app.services.audit import record_audit 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 from app.services.privacy import Visibility
@@ -86,6 +86,32 @@ async def create_person(
return 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( async def list_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]: ) -> list[Person]:
@@ -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()
+112
View File
@@ -0,0 +1,112 @@
"""Source service. Sources are reusable, tree-scoped records of an origin.
Writes require editor rights; reads go through the privacy engine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.source import Source
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, NotFound
async def create_source(
session: AsyncSession,
*,
actor: User,
tree: Tree,
title: str,
author: str | None = None,
source_type: str | None = None,
repository: str | None = None,
url: str | None = None,
citation_text: str | None = None,
publication_info: str | None = None,
quality_note: str | None = None,
) -> Source:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = Source(
tree_id=tree.id,
title=title,
author=author,
source_type=source_type,
repository=repository,
url=url,
citation_text=citation_text,
publication_info=publication_info,
quality_note=quality_note,
)
session.add(source)
await session.flush()
record_audit(
session,
action="create",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"title": title},
)
await session.commit()
await session.refresh(source)
return source
async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Source]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Source)
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
.order_by(Source.title)
)
return list((await session.execute(stmt)).scalars().all())
async def get_source(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, source_id: uuid.UUID
) -> Source:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
return source
async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_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")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
source.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+116
View File
@@ -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
+96
View File
@@ -0,0 +1,96 @@
"""Sources and citations (the provenance spine)."""
import uuid
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tree_id = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
person = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Cy"}, headers=h
)
).json()["id"]
event = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": person},
headers=h,
)
).json()["id"]
return h, tree_id, person, event
async def test_source_and_citation_flow(client):
h, tree_id, person, event = await _setup(client, "src1@example.com")
src = await client.post(
f"/api/v1/trees/{tree_id}/sources",
json={"title": "1880 Census", "repository": "NARA"},
headers=h,
)
assert src.status_code == 201, src.text
source_id = src.json()["id"]
assert len((await client.get(f"/api/v1/trees/{tree_id}/sources", headers=h)).json()) == 1
# Cite the source on the birth event.
cite = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": source_id, "event_id": event, "page": "p. 4"},
headers=h,
)
assert cite.status_code == 201, cite.text
citation_id = cite.json()["id"]
citations = (await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()
assert len(citations) == 1
assert citations[0]["event_id"] == event
assert citations[0]["source_id"] == source_id
resp = await client.delete(f"/api/v1/trees/{tree_id}/citations/{citation_id}", headers=h)
assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()) == 0
async def test_citation_needs_exactly_one_target(client):
h, tree_id, person, event = await _setup(client, "src2@example.com")
source_id = (
await client.post(
f"/api/v1/trees/{tree_id}/sources", json={"title": "X"}, headers=h
)
).json()["id"]
# No target.
r = await client.post(
f"/api/v1/trees/{tree_id}/citations", json={"source_id": source_id}, headers=h
)
assert r.status_code == 409
# Two targets.
r = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": source_id, "person_id": person, "event_id": event},
headers=h,
)
assert r.status_code == 409
async def test_citation_unknown_source_404(client):
h, tree_id, person, _ = await _setup(client, "src3@example.com")
r = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": str(uuid.uuid4()), "person_id": person},
headers=h,
)
assert r.status_code == 404
async def test_non_member_cannot_create_source(client):
h, tree_id, _, _ = await _setup(client, "src4@example.com")
other = auth(await register(client, "src-intruder@example.com"))
r = await client.post(
f"/api/v1/trees/{tree_id}/sources", json={"title": "nope"}, headers=other
)
assert r.status_code == 403
+3 -3
View File
@@ -31,9 +31,9 @@ S3_REGION=us-east-1
PROVENANCE_SITE_ADDRESS=:80 PROVENANCE_SITE_ADDRESS=:80
# --- Deploy-host services (optional, selected via COMPOSE_PROFILES) --- # --- Deploy-host services (optional, selected via COMPOSE_PROFILES) ---
# 'tunnel' -> cloudflared connector (needs CLOUDFLARE_TUNNEL_TOKEN; public hostname -> http://caddy:80) # 'tunnel' -> cloudflared connector (needs CLOUDFLARE_TUNNEL_TOKEN; public hostname -> http://caddy:80)
# 'watchtower' -> auto-pull updated backend/frontend images every 2 min (needs `docker login git.jpaul.io` on the host) # Auto-deploy is handled by the host's global Watchtower (watches the
# Combine with commas. On the lab host: COMPOSE_PROFILES=tunnel,watchtower # watchtower-enabled backend/frontend labels) — no profile needed here.
CLOUDFLARE_TUNNEL_TOKEN= CLOUDFLARE_TUNNEL_TOKEN=
COMPOSE_PROFILES= COMPOSE_PROFILES=
+5 -14
View File
@@ -108,20 +108,11 @@ services:
profiles: profiles:
- tunnel - tunnel
# Auto-deploy: watch the label-enabled app containers (backend, frontend), # Auto-deploy is handled by the host's global Watchtower (a single
# poll the registry every 2 minutes, and recreate on a new :test-main digest. # nickfedor/watchtower instance watches every container labelled
# Scoped by label so it never touches Postgres/MinIO/Caddy. Registry creds come # `com.centurylinklabs.watchtower.enable=true` across all stacks). The backend
# from the host docker config (the `docker login git.jpaul.io` on the host). # and frontend carry that label above, so a new :test-main image is pulled and
# Opt-in via the "watchtower" profile. # the container recreated automatically — no per-stack Watchtower needed.
watchtower:
image: containrrr/watchtower:latest
restart: unless-stopped
command: --label-enable --cleanup --interval 120
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HOME:-/root}/.docker/config.json:/config.json:ro
profiles:
- watchtower
volumes: volumes:
pgdata: pgdata:
+17 -8
View File
@@ -56,9 +56,14 @@ export default function TreeDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline"> <div className="flex items-center justify-between">
All trees <Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
</Link> All trees
</Link>
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
Sources
</Link>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -81,11 +86,15 @@ export default function TreeDetailPage() {
<ul className="space-y-2"> <ul className="space-y-2">
{persons.map((person) => ( {persons.map((person) => (
<li key={person.id}> <li key={person.id}>
<Card> <Link href={`/trees/${treeId}/persons/${person.id}`}>
<CardContent className="p-4"> <Card className="transition-colors hover:border-bronze/50">
{person.primary_name ?? <span className="text-[var(--muted)]">Unnamed</span>} <CardContent className="p-4">
</CardContent> {person.primary_name ?? (
</Card> <span className="text-[var(--muted)]">Unnamed</span>
)}
</CardContent>
</Card>
</Link>
</li> </li>
))} ))}
</ul> </ul>
@@ -0,0 +1,371 @@
"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"];
type Source = components["schemas"]["SourceRead"];
type Citation = components["schemas"]["CitationRead"];
type CitationCreate = components["schemas"]["CitationCreate"];
const fieldCls = "h-9 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<Person | null>(null);
const [people, setPeople] = useState<Person[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [citations, setCitations] = useState<Citation[]>([]);
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<Qualifier>("biological");
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
const [citeFor, setCiteFor] = useState<string | null>(null);
const [citeSource, setCiteSource] = useState("");
const [citePage, setCitePage] = useState("");
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, src, cit] = 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 } },
}),
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
]);
setPeople(all.data ?? []);
setEvents(ev.data ?? []);
setRels(rl.data ?? []);
setSources(src.data ?? []);
setCitations(cit.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 sourceName = useMemo(() => {
const m = new Map(sources.map((s) => [s.id, s.title]));
return (id: string) => m.get(id) ?? "source";
}, [sources]);
const others = people.filter((p) => p.id !== personId);
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
const partners = rels.filter((r) => r.type === "partnership");
const siblings = rels.filter((r) => r.type === "sibling");
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
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();
}
async function addCitation(target: Partial<CitationCreate>) {
if (!citeSource) return;
const body: CitationCreate = { source_id: citeSource, page: citePage || null, ...target };
const { error } = await api.POST("/api/v1/trees/{tree_id}/citations", {
params: { path: { tree_id: treeId } },
body,
});
if (!error) {
setCiteFor(null);
setCiteSource("");
setCitePage("");
load();
}
}
async function removeCitation(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/citations/{citation_id}", {
params: { path: { tree_id: treeId, citation_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
// Inline "cite" control: a badge with count, a toggle, and the picker form.
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
return (
<span className="inline-flex items-center gap-2">
{cites.length > 0 && (
<span
className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze"
title={cites.map((c) => sourceName(c.source_id)).join(", ")}
>
{cites.length} sourced
</span>
)}
{citeFor === key ? (
<form
onSubmit={(e) => {
e.preventDefault();
addCitation(target);
}}
className="inline-flex items-center gap-1"
>
<select
className={fieldCls}
value={citeSource}
onChange={(e) => setCiteSource(e.target.value)}
>
<option value=""> source </option>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
<input
className={`${fieldCls} w-24`}
placeholder="page"
value={citePage}
onChange={(e) => setCitePage(e.target.value)}
/>
<Button type="submit" size="sm">
cite
</Button>
<button
type="button"
onClick={() => setCiteFor(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
) : sources.length === 0 ? (
<Link href={`/trees/${treeId}/sources`} className="text-xs text-[var(--muted)] hover:underline">
+ add a source first
</Link>
) : (
<button
type="button"
onClick={() => {
setCiteFor(key);
setCiteSource("");
setCitePage("");
}}
className="text-xs text-bronze hover:underline"
>
+ cite
</button>
)}
</span>
);
}
const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) =>
items.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
<ul className="mt-1 space-y-1">
{items.map((r) => (
<li key={r.id} className="flex items-center justify-between text-sm">
<Link href={`/trees/${treeId}/persons/${otherId(r)}`} className="hover:underline">
{nameOf(otherId(r))}
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
</Link>
<button
onClick={() => removeRel(r.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</li>
))}
</ul>
</div>
);
return (
<div className="space-y-6">
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
Back to tree
</Link>
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
{citeControl("p", { person_id: personId }, personCites)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Life events</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{events.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No events yet.</p>
) : (
<ul className="space-y-2">
{events.map((ev) => (
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
<span className="font-medium capitalize">{ev.event_type}</span>
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
</span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
))}
</ul>
)}
<form onSubmit={addEvent} className="flex flex-wrap gap-2">
<Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} />
<Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} />
<Button type="submit">Add event</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Relationships</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rels.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No relationships yet.</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{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,
)}
</div>
)}
{others.length === 0 ? (
<p className="text-sm text-[var(--muted)]">Add more people to the tree to link them.</p>
) : (
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--muted)]">Add</span>
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
<option value="parent">parent</option>
<option value="child">child</option>
<option value="partner">partner</option>
<option value="sibling">sibling</option>
</select>
<select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}>
<option value=""> person </option>
{others.map((p) => (
<option key={p.id} value={p.id}>
{p.primary_name ?? "Unnamed"}
</option>
))}
</select>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
)}
<Button type="submit">Link</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Source = components["schemas"]["SourceRead"];
export default function SourcesPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [sources, setSources] = useState<Source[]>([]);
const [ready, setReady] = useState(false);
const [title, setTitle] = useState("");
const [repository, setRepository] = useState("");
const [url, setUrl] = useState("");
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/sources", {
params: { path: { tree_id: treeId } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setSources(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function add(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
const { error } = await api.POST("/api/v1/trees/{tree_id}/sources", {
params: { path: { tree_id: treeId } },
body: { title, repository: repository || null, url: url || null },
});
if (!error) {
setTitle("");
setRepository("");
setUrl("");
load();
}
}
async function remove(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/sources/{source_id}", {
params: { path: { tree_id: treeId, source_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
Back to tree
</Link>
<h1 className="text-2xl font-bold">Sources</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">New source</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={add} className="flex flex-wrap gap-2">
<Input
className="w-56"
placeholder="Title (e.g. 1880 US Census)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
className="w-40"
placeholder="Repository"
value={repository}
onChange={(e) => setRepository(e.target.value)}
/>
<Input
className="w-48"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Button type="submit">Add source</Button>
</form>
</CardContent>
</Card>
{sources.length === 0 ? (
<p className="text-[var(--muted)]">No sources yet add one above, then cite it on facts.</p>
) : (
<ul className="space-y-2">
{sources.map((s) => (
<li key={s.id}>
<Card>
<CardContent className="flex items-start justify-between gap-3 p-4">
<div>
<div className="font-medium">{s.title}</div>
<div className="text-sm text-[var(--muted)]">
{[s.repository, s.url].filter(Boolean).join(" · ")}
</div>
</div>
<button
onClick={() => remove(s.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
+884
View File
@@ -210,10 +210,330 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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;
};
"/api/v1/trees/{tree_id}/sources": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Sources */
get: operations["list_sources_api_v1_trees__tree_id__sources_get"];
put?: never;
/** Create Source */
post: operations["create_source_api_v1_trees__tree_id__sources_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/sources/{source_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Source */
get: operations["get_source_api_v1_trees__tree_id__sources__source_id__get"];
put?: never;
post?: never;
/** Delete Source */
delete: operations["delete_source_api_v1_trees__tree_id__sources__source_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/citations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Citations */
get: operations["list_citations_api_v1_trees__tree_id__citations_get"];
put?: never;
/** Create Citation */
post: operations["create_citation_api_v1_trees__tree_id__citations_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/citations/{citation_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Citation */
delete: operations["delete_citation_api_v1_trees__tree_id__citations__citation_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/**
* CitationConfidence
* @enum {string}
*/
CitationConfidence: "high" | "medium" | "low";
/** CitationCreate */
CitationCreate: {
/**
* Source Id
* Format: uuid
*/
source_id: string;
/** Person Id */
person_id?: string | null;
/** Event Id */
event_id?: string | null;
/** Name Id */
name_id?: string | null;
/** Relationship Id */
relationship_id?: string | null;
/** Page */
page?: string | null;
/** Detail */
detail?: string | null;
confidence?: components["schemas"]["CitationConfidence"] | null;
};
/** CitationRead */
CitationRead: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Tree Id
* Format: uuid
*/
tree_id: string;
/**
* Source Id
* Format: uuid
*/
source_id: string;
/** Person Id */
person_id: string | null;
/** Event Id */
event_id: string | null;
/** Name Id */
name_id: string | null;
/** Relationship Id */
relationship_id: string | null;
/** Page */
page: string | null;
/** Detail */
detail: string | null;
confidence: components["schemas"]["CitationConfidence"] | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** 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 */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -226,6 +546,13 @@ export interface components {
/** Password */ /** Password */
password: string; 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 */
PasswordResetConfirm: { PasswordResetConfirm: {
/** Token */ /** Token */
@@ -293,6 +620,60 @@ export interface components {
/** Display Name */ /** Display Name */
display_name?: string | null; 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 */
SessionRead: { SessionRead: {
user: components["schemas"]["UserRead"]; user: components["schemas"]["UserRead"];
@@ -304,6 +685,59 @@ export interface components {
*/ */
expires_at: string; expires_at: string;
}; };
/** SourceCreate */
SourceCreate: {
/** Title */
title: string;
/** Author */
author?: string | null;
/** Source Type */
source_type?: string | null;
/** Repository */
repository?: string | null;
/** Url */
url?: string | null;
/** Citation Text */
citation_text?: string | null;
/** Publication Info */
publication_info?: string | null;
/** Quality Note */
quality_note?: string | null;
};
/** SourceRead */
SourceRead: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Tree Id
* Format: uuid
*/
tree_id: string;
/** Title */
title: string;
/** Author */
author: string | null;
/** Source Type */
source_type: string | null;
/** Repository */
repository: string | null;
/** Url */
url: string | null;
/** Citation Text */
citation_text: string | null;
/** Publication Info */
publication_info: string | null;
/** Quality Note */
quality_note: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** TokenRequest */ /** TokenRequest */
TokenRequest: { TokenRequest: {
/** Token */ /** Token */
@@ -782,4 +1216,454 @@ 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"];
};
};
};
};
list_sources_api_v1_trees__tree_id__sources_get: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SourceRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_source_api_v1_trees__tree_id__sources_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SourceCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SourceRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_source_api_v1_trees__tree_id__sources__source_id__get: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
source_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SourceRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_source_api_v1_trees__tree_id__sources__source_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
source_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"];
};
};
};
};
list_citations_api_v1_trees__tree_id__citations_get: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CitationRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_citation_api_v1_trees__tree_id__citations_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CitationCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CitationRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_citation_api_v1_trees__tree_id__citations__citation_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
citation_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"];
};
};
};
};
} }
File diff suppressed because it is too large Load Diff