7 Commits

Author SHA1 Message Date
justin cf5518c7ec Full-CRUD sweep: update endpoints for tree, source, citation, relationship, media
Closes the rule #8 gap at the API layer: PATCH endpoints + service updates for Tree (name/description/visibility), Source, Citation (page/detail/confidence), Relationship (qualifier/notes), and Media (title/attachment) — editor-gated and audited. Every core entity now has create/read/update/delete. Edit UIs for these land in the frontend batch. 37 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:53:17 -04:00
justin 26df03cfd7 Merge pull request 'Edit people + events; existing-person picker; full-CRUD rule' (#17) from crud-edits into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m25s
2026-06-07 09:35:56 -04:00
justin ab064bce6e Edit UI for people and life events; existing-person picker in family view
Person detail: an Edit form for name + gender + living status + privacy, and inline edit of each life event (type + structured date). Family view: the add-relative buttons now search existing people (link the real person) or create new — preventing duplicate spouses/parents — and adding a child to someone with one spouse links both parents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 76b7f453c1 Add update (CRUD) for events and people; record the full-CRUD invariant
Events and people are now editable, not write-once: PATCH /events/{id} (type, structured date, place, notes) and PATCH /persons/{id} (vitals, privacy, and the primary name's given/surname). CLAUDE.md gains rule #8: every stored object must support full CRUD in API and UI — historical research is constant correction. Tests cover both updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 438d2db2e7 Merge pull request 'Tree layout toggles + fan + card->profile + server search' (#16) from phase2-tree-toggles into main
build-frontend / build (push) Successful in 1m26s
2026-06-07 08:01:32 -04:00
justin 99913ada94 Tree layout toggles (landscape/portrait/fan), card->profile, server search
Tree page gets Landscape/Portrait/Fan toggles: landscape & portrait via family-chart's orientation; a hand-rolled radial Fan chart of ancestors (rings per generation, click to recenter). Clicking a card recenters and updates an 'Open <name> →' link to that person's profile. The People directory search now hits the server-side pg_trgm fuzzy endpoint (debounced) so it spans the whole tree, not just the loaded page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 08:01:31 -04:00
justin 584b323121 Merge pull request 'Fuzzy search (pg_trgm) + living-person protection' (#15) from phase2-search-privacy into main
build-backend / build (push) Successful in 30s
2026-06-07 07:55:14 -04:00
30 changed files with 1603 additions and 148 deletions
+1
View File
@@ -19,6 +19,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact. 5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe). 6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys. 7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
8. **Full CRUD on every object.** Every stored entity (person, name, event, relationship, source, citation, media, tree, …) must support create, read, **update**, and delete — in the API *and* the UI. Historical research is constant correction and new information, so nothing is write-once. Any new feature or data type ships with all four operations; an entity you can create but not edit is a bug.
## Tech stack ## Tech stack
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead from app.schemas.source import CitationCreate, CitationRead, CitationUpdate
from app.services import citation_service, tree_service from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"]) router = APIRouter(prefix="/trees", tags=["citations"])
@@ -31,6 +31,25 @@ async def list_citations(
return [CitationRead.model_validate(c) for c in citations] return [CitationRead.model_validate(c) for c in citations]
@router.patch("/{tree_id}/citations/{citation_id}", response_model=CitationRead)
async def update_citation(
tree_id: uuid.UUID,
citation_id: uuid.UUID,
data: CitationUpdate,
session: SessionDep,
current: CurrentUser,
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.update_citation(
session,
actor=current,
tree=tree,
citation_id=citation_id,
changes=data.model_dump(exclude_unset=True),
)
return CitationRead.model_validate(citation)
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation( async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.event import EventCreate, EventRead from app.schemas.event import EventCreate, EventRead, EventUpdate
from app.services import event_service, tree_service from app.services import event_service, tree_service
router = APIRouter(prefix="/trees", tags=["events"]) router = APIRouter(prefix="/trees", tags=["events"])
@@ -40,6 +40,25 @@ async def list_person_events(
return [EventRead.model_validate(e) for e in events] return [EventRead.model_validate(e) for e in events]
@router.patch("/{tree_id}/events/{event_id}", response_model=EventRead)
async def update_event(
tree_id: uuid.UUID,
event_id: uuid.UUID,
data: EventUpdate,
session: SessionDep,
current: CurrentUser,
) -> EventRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
event = await event_service.update_event(
session,
actor=current,
tree=tree,
event_id=event_id,
changes=data.model_dump(exclude_unset=True),
)
return EventRead.model_validate(event)
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event( async def delete_event(
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
+21 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile, status from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead from app.schemas.media import MediaRead, MediaUpdate
from app.services import media_service, tree_service from app.services import media_service, tree_service
@@ -81,6 +81,26 @@ async def media_content(
) )
@router.patch("/{tree_id}/media/{media_id}", response_model=MediaRead)
async def update_media(
tree_id: uuid.UUID,
media_id: uuid.UUID,
data: MediaUpdate,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> MediaRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.update_media(
session,
actor=current,
tree=tree,
media_id=media_id,
changes=data.model_dump(exclude_unset=True),
)
return _read(media)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media( async def delete_media(
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
from app.services import person_service, tree_service from app.services import person_service, tree_service
# Persons are nested under their tree (the tenant boundary). # Persons are nested under their tree (the tenant boundary).
@@ -56,6 +56,25 @@ async def list_persons(
return [PersonRead.model_validate(p) for p in persons] return [PersonRead.model_validate(p) for p in persons]
@router.patch("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def update_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: PersonUpdate,
session: SessionDep,
current: CurrentUser,
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.update_person(
session,
actor=current,
tree=tree,
person_id=person_id,
changes=data.model_dump(exclude_unset=True),
)
return PersonRead.model_validate(person)
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_person( async def delete_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.relationship import RelationshipCreate, RelationshipRead from app.schemas.relationship import RelationshipCreate, RelationshipRead, RelationshipUpdate
from app.services import relationship_service, tree_service from app.services import relationship_service, tree_service
router = APIRouter(prefix="/trees", tags=["relationships"]) router = APIRouter(prefix="/trees", tags=["relationships"])
@@ -47,6 +47,25 @@ async def list_person_relationships(
return [RelationshipRead.model_validate(r) for r in rels] return [RelationshipRead.model_validate(r) for r in rels]
@router.patch("/{tree_id}/relationships/{relationship_id}", response_model=RelationshipRead)
async def update_relationship(
tree_id: uuid.UUID,
relationship_id: uuid.UUID,
data: RelationshipUpdate,
session: SessionDep,
current: CurrentUser,
) -> RelationshipRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rel = await relationship_service.update_relationship(
session,
actor=current,
tree=tree,
relationship_id=relationship_id,
changes=data.model_dump(exclude_unset=True),
)
return RelationshipRead.model_validate(rel)
@router.delete( @router.delete(
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT "/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
) )
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead from app.schemas.source import SourceCreate, SourceRead, SourceUpdate
from app.services import source_service, tree_service from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"]) router = APIRouter(prefix="/trees", tags=["sources"])
@@ -40,6 +40,25 @@ async def get_source(
return SourceRead.model_validate(source) return SourceRead.model_validate(source)
@router.patch("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def update_source(
tree_id: uuid.UUID,
source_id: uuid.UUID,
data: SourceUpdate,
session: SessionDep,
current: CurrentUser,
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.update_source(
session,
actor=current,
tree=tree,
source_id=source_id,
changes=data.model_dump(exclude_unset=True),
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source( async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
+11 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.tree import TreeCreate, TreeRead from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate
from app.services import tree_service from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"]) router = APIRouter(prefix="/trees", tags=["trees"])
@@ -38,6 +38,16 @@ async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
return TreeRead.model_validate(tree) return TreeRead.model_validate(tree)
@router.patch("/{tree_id}", response_model=TreeRead)
async def update_tree(
tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser
) -> TreeRead:
tree = await tree_service.update_tree(
session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True)
)
return TreeRead.model_validate(tree)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None: async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
await tree_service.delete_tree(session, actor=current, tree_id=tree_id) await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
+13
View File
@@ -20,6 +20,19 @@ class EventCreate(BaseModel):
notes: str | None = None notes: str | None = None
class EventUpdate(BaseModel):
# All optional; only fields explicitly sent are changed (PATCH semantics).
event_type: str | 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 | None = None
detail: str | None = None
notes: str | None = None
class EventRead(BaseModel): class EventRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+7
View File
@@ -4,6 +4,13 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
class MediaUpdate(BaseModel):
title: str | None = None
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
source_id: uuid.UUID | None = None
class MediaRead(BaseModel): class MediaRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+10
View File
@@ -15,6 +15,16 @@ class PersonCreate(BaseModel):
notes: str | None = None notes: str | None = None
class PersonUpdate(BaseModel):
# Person fields + the primary name's parts; only sent fields are changed.
given: str | None = None
surname: str | None = None
gender: str | None = None
is_living: bool | None = None
privacy: PersonPrivacy | None = None
notes: str | None = None
class PersonRead(BaseModel): class PersonRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+5
View File
@@ -15,6 +15,11 @@ class RelationshipCreate(BaseModel):
notes: str | None = None notes: str | None = None
class RelationshipUpdate(BaseModel):
qualifier: ParentChildQualifier | None = None
notes: str | None = None
class RelationshipRead(BaseModel): class RelationshipRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+17
View File
@@ -33,6 +33,23 @@ class SourceRead(BaseModel):
created_at: datetime created_at: datetime
class SourceUpdate(BaseModel):
title: str | None = None
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 CitationUpdate(BaseModel):
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationCreate(BaseModel): class CitationCreate(BaseModel):
source_id: uuid.UUID source_id: uuid.UUID
# Exactly one target fact. # Exactly one target fact.
+6
View File
@@ -12,6 +12,12 @@ class TreeCreate(BaseModel):
visibility: TreeVisibility = TreeVisibility.private visibility: TreeVisibility = TreeVisibility.private
class TreeUpdate(BaseModel):
name: str | None = None
description: str | None = None
visibility: TreeVisibility | None = None
class TreeRead(BaseModel): class TreeRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+32
View File
@@ -113,6 +113,38 @@ async def list_citations(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID, changes: dict
) -> Citation:
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")
for key in {"page", "detail", "confidence"} & changes.keys():
setattr(citation, key, changes[key])
record_audit(
session,
action="update",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(citation)
return citation
async def delete_citation( async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
) -> None: ) -> None:
+38
View File
@@ -122,6 +122,44 @@ async def list_events_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_event(
session: AsyncSession,
*,
actor: User,
tree: Tree,
event_id: uuid.UUID,
changes: dict,
) -> Event:
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")
if "place_id" in changes and changes["place_id"] is not None:
if not await _belongs_to_tree(session, Place, changes["place_id"], tree.id):
raise NotFound("place not found in this tree")
for key, value in changes.items():
setattr(event, key, value)
record_audit(
session,
action="update",
entity_type="Event",
entity_id=event.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(event)
return event
async def delete_event( async def delete_event(
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
) -> None: ) -> None:
+30
View File
@@ -97,6 +97,36 @@ async def get_media(
return media return media
async def update_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID, changes: dict
) -> Media:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
for key in {"title", "person_id", "event_id", "source_id"} & changes.keys():
setattr(media, key, changes[key])
record_audit(
session,
action="update",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(media)
return media
async def delete_media( async def delete_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
) -> None: ) -> None:
+53
View File
@@ -95,6 +95,59 @@ async def create_person(
return person return person
_PERSON_FIELDS = {"gender", "is_living", "privacy", "notes"}
async def update_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID, changes: dict
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
for key in _PERSON_FIELDS & changes.keys():
setattr(person, key, changes[key])
if "given" in changes or "surname" in changes:
name = (
await session.execute(
select(Name)
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().first()
if name is None:
name = Name(tree_id=tree.id, person_id=person.id, name_type="birth", is_primary=True)
session.add(name)
if "given" in changes:
name.given = changes["given"]
if "surname" in changes:
name.surname = changes["surname"]
name.display_name = None # rebuild display from parts
record_audit(
session,
action="update",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def get_person( async def get_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> Person: ) -> Person:
@@ -107,6 +107,44 @@ async def list_relationships_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID, changes: dict
) -> Relationship:
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")
if (
"qualifier" in changes
and changes["qualifier"] is not None
and relationship.type is not RelationshipType.parent_child
):
raise Conflict("qualifier only applies to parent_child relationships")
for key in {"qualifier", "notes"} & changes.keys():
setattr(relationship, key, changes[key])
record_audit(
session,
action="update",
entity_type="Relationship",
entity_id=relationship.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(relationship)
return relationship
async def delete_relationship( async def delete_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID
) -> None: ) -> None:
+36
View File
@@ -86,6 +86,42 @@ async def get_source(
return source return source
_SOURCE_FIELDS = {
"title", "author", "source_type", "repository", "url", "citation_text",
"publication_info", "quality_note",
}
async def update_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID, changes: dict
) -> Source:
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")
for key in _SOURCE_FIELDS & changes.keys():
setattr(source, key, changes[key])
record_audit(
session,
action="update",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(source)
return source
async def delete_source( async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
) -> None: ) -> None:
+24
View File
@@ -62,6 +62,30 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
return tree return tree
async def update_tree(
session: AsyncSession, *, actor: User, tree_id: uuid.UUID, changes: dict
) -> Tree:
tree = await BaseRepository(session, Tree).get(tree_id)
if tree is None:
raise NotFound("tree not found")
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
for key in {"name", "description", "visibility"} & changes.keys():
setattr(tree, key, changes[key])
record_audit(
session,
action="update",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(tree)
return tree
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree: async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
"""Load a tree (including soft-deleted) and require the actor be its owner.""" """Load a tree (including soft-deleted) and require the actor be its owner."""
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True) tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
+19
View File
@@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client):
assert resp.status_code == 403 assert resp.status_code == 403
async def test_person_update(client):
token = await register(client, "edit@example.com")
h = auth(token)
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Jon", "surname": "Smith"}, headers=h
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tid}/persons/{pid}",
json={"given": "John", "gender": "male"},
headers=auth(token),
)
assert resp.status_code == 200, resp.text
assert resp.json()["primary_name"] == "John Smith"
assert resp.json()["gender"] == "male"
async def test_auth_required_without_token(client): async def test_auth_required_without_token(client):
resp = await client.get("/api/v1/trees") resp = await client.get("/api/v1/trees")
assert resp.status_code == 401 assert resp.status_code == 401
+79
View File
@@ -0,0 +1,79 @@
"""Update (the U in CRUD) for the remaining entities — rule #8."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def test_tree_update(client):
h, tid = await _setup(client, "u-tree@example.com")
r = await client.patch(
f"/api/v1/trees/{tid}", json={"name": "Renamed", "visibility": "unlisted"}, headers=h
)
assert r.status_code == 200
assert r.json()["name"] == "Renamed" and r.json()["visibility"] == "unlisted"
async def test_source_update(client):
h, tid = await _setup(client, "u-src@example.com")
sid = (
await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "Old"}, headers=h)
).json()["id"]
r = await client.patch(
f"/api/v1/trees/{tid}/sources/{sid}",
json={"title": "New", "repository": "NARA"},
headers=h,
)
assert r.status_code == 200
assert r.json()["title"] == "New" and r.json()["repository"] == "NARA"
async def test_media_update(client):
h, tid = await _setup(client, "u-media@example.com")
mid = (
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("a.txt", b"x", "text/plain")},
data={"title": "old"},
headers=h,
)
).json()["id"]
r = await client.patch(f"/api/v1/trees/{tid}/media/{mid}", json={"title": "new"}, headers=h)
assert r.status_code == 200 and r.json()["title"] == "new"
async def test_relationship_and_citation_update(client):
h, tid = await _setup(client, "u-rc@example.com")
async def mk(path, body):
return (await client.post(f"/api/v1/trees/{tid}/{path}", json=body, headers=h)).json()["id"]
p1 = await mk("persons", {"given": "A"})
p2 = await mk("persons", {"given": "B"})
rid = await mk(
"relationships",
{
"type": "parent_child",
"person_from_id": p1,
"person_to_id": p2,
"qualifier": "biological",
},
)
r = await client.patch(
f"/api/v1/trees/{tid}/relationships/{rid}", json={"qualifier": "adoptive"}, headers=h
)
assert r.status_code == 200 and r.json()["qualifier"] == "adoptive"
src = await mk("sources", {"title": "S"})
cid = await mk("citations", {"source_id": src, "person_id": p1})
r2 = await client.patch(
f"/api/v1/trees/{tid}/citations/{cid}",
json={"page": "p.7", "confidence": "high"},
headers=h,
)
assert r2.status_code == 200
assert r2.json()["page"] == "p.7" and r2.json()["confidence"] == "high"
+19
View File
@@ -48,6 +48,25 @@ async def test_event_create_list_delete(client):
assert len(listed.json()) == 0 assert len(listed.json()) == 0
async def test_event_update(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "evupd@example.com")
eid = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": parent, "date_value": "1850"},
headers=h,
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tree_id}/events/{eid}",
json={"date_value": "ABT 1851", "event_type": "baptism"},
headers=h,
)
assert resp.status_code == 200, resp.text
assert resp.json()["date_value"] == "ABT 1851"
assert resp.json()["event_type"] == "baptism"
async def test_event_requires_exactly_one_subject(client): async def test_event_requires_exactly_one_subject(client):
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com") h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
resp = await client.post( resp = await client.post(
+87 -33
View File
@@ -34,6 +34,7 @@ export default function FamilyViewPage() {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null); const [focusId, setFocusId] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [results, setResults] = useState<Person[] | null>(null); // server fuzzy search
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name. // Inline add-relative form: which anchor + kind is open, and the typed name.
// `key` keeps each empty slot's inline form independent (a person has 2 // `key` keeps each empty slot's inline form independent (a person has 2
@@ -65,6 +66,22 @@ export default function FamilyViewPage() {
load(); load();
}, [load]); }, [load]);
// Debounced server-side fuzzy search (pg_trgm) across the whole tree.
useEffect(() => {
const q = search.trim();
if (!q) {
setResults(null);
return;
}
const t = setTimeout(async () => {
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { q } },
});
setResults(data ?? []);
}, 250);
return () => clearTimeout(t);
}, [search, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]); const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
const parentsOf = (id: string) => const parentsOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id); rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
@@ -105,23 +122,42 @@ export default function FamilyViewPage() {
load(); load();
} }
async function postRel(body: components["schemas"]["RelationshipCreate"]) {
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
// Create the relationship(s) connecting an (existing or new) person to anchor.
async function createLink(kind: AddKind, anchor: string, personId: string) {
if (kind === "parent") {
await postRel({ type: "parent_child", person_from_id: personId, person_to_id: anchor, qualifier: "biological" });
} else if (kind === "partner") {
await postRel({ type: "partnership", person_from_id: anchor, person_to_id: personId });
} else {
// child: link to anchor, and to anchor's spouse too (so both parents show)
await postRel({ type: "parent_child", person_from_id: anchor, person_to_id: personId, qualifier: "biological" });
const partners = partnersOf(anchor);
if (partners.length === 1) {
await postRel({ type: "parent_child", person_from_id: partners[0], person_to_id: personId, qualifier: "biological" });
}
}
}
async function linkExisting(personId: string) {
if (!adding) return;
await createLink(adding.kind, adding.anchor, personId);
setAdding(null);
setAddName("");
load();
}
async function submitAdd(e: React.FormEvent) { async function submitAdd(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!adding || !addName.trim()) return; if (!adding || !addName.trim()) return;
const newId = await addPerson(addName); const newId = await addPerson(addName);
if (newId) { if (newId) await createLink(adding.kind, adding.anchor, newId);
const { kind, anchor } = adding;
const body =
kind === "parent"
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
: kind === "child"
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
setAdding(null); setAdding(null);
setAddName(""); setAddName("");
load(); load();
@@ -193,26 +229,45 @@ export default function FamilyViewPage() {
label: string; label: string;
}) => }) =>
adding?.key === formKey ? ( adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1"> <form onSubmit={submitAdd} className="flex w-56 flex-col gap-1">
<Input <Input
autoFocus autoFocus
className="h-9" className="h-9"
placeholder="Full name" placeholder="Search existing or type a new name"
value={addName} value={addName}
onChange={(e) => setAddName(e.target.value)} onChange={(e) => setAddName(e.target.value)}
/> />
<div className="flex gap-1"> {addName.trim() && (
<Button type="submit" size="sm"> <div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
Add {people
</Button> .filter(
<button (p) =>
type="button" p.id !== anchor &&
onClick={() => setAdding(null)} (p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
className="text-xs text-[var(--muted)]" )
> .slice(0, 6)
cancel .map((p) => (
</button> <button
</div> key={p.id}
type="button"
onClick={() => linkExisting(p.id)}
className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-bronze/[0.07]"
>
<span className="truncate">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
</button>
))}
<button
type="submit"
className="flex w-full items-center gap-1 border-t border-[var(--border)] px-2 py-1.5 text-left text-bronze hover:bg-bronze/[0.07]"
>
+ Create new {addName.trim()}
</button>
</div>
)}
<button type="button" onClick={() => setAdding(null)} className="text-xs text-[var(--muted)]">
cancel
</button>
</form> </form>
) : ( ) : (
<button <button
@@ -265,10 +320,9 @@ export default function FamilyViewPage() {
const sorted = [...people].sort((a, b) => const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""), (a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
); );
const matches = search // Server fuzzy results when searching; otherwise the loaded set.
? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(search.toLowerCase())) const directory = results ?? sorted;
: sorted; const shown = directory.slice(0, 200); // cap DOM nodes; refine search to narrow
const shown = matches.slice(0, 200); // cap DOM nodes; refine search to narrow
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -358,9 +412,9 @@ export default function FamilyViewPage() {
)) ))
)} )}
</div> </div>
{matches.length > shown.length && ( {directory.length > shown.length && (
<div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]"> <div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]">
Showing {shown.length} of {matches.length} refine your search to narrow. Showing {shown.length} of {directory.length} refine your search to narrow.
</div> </div>
)} )}
</Card> </Card>
@@ -33,6 +33,53 @@ const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SE
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" }; const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
const pad = (n: number, len: number) => String(n).padStart(len, "0"); const pad = (n: number, len: number) => String(n).padStart(len, "0");
function composeDate(qual: string, day: string, month: string, year: string) {
const y = year.trim();
if (!y || Number.isNaN(Number(y))) {
return { date_value: null as string | null, date_start: null as string | null, date_precision: null as string | null };
}
const m = month ? Number(month) : null;
const d = day.trim() ? Number(day) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(y);
const prefix = DATE_QUALS[qual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(y), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: qual,
};
}
// Parse a stored date_value (e.g. "ABT 12 MAR 1900") back into form fields.
function parseDateValue(v: string | null | undefined) {
let qual = "exact";
let day = "";
let month = "";
let year = "";
if (v) {
let s = v.trim();
const up = s.toUpperCase();
for (const [q, pre] of Object.entries(DATE_QUALS)) {
if (pre && up.startsWith(`${pre} `)) {
qual = q;
s = s.slice(pre.length + 1).trim();
break;
}
}
for (const t of s.toUpperCase().split(/\s+/).filter(Boolean)) {
if (/^\d{3,4}$/.test(t) && !year) year = t;
else if (/^\d{1,2}$/.test(t)) day = String(Number(t));
else {
const mi = GED_MON.indexOf(t);
if (mi > 0) month = String(mi);
}
}
}
return { qual, day, month, year };
}
export default function PersonDetailPage() { export default function PersonDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string; personId: string }>(); const params = useParams<{ id: string; personId: string }>();
@@ -54,6 +101,23 @@ export default function PersonDetailPage() {
const [dateMonth, setDateMonth] = useState(""); const [dateMonth, setDateMonth] = useState("");
const [dateYear, setDateYear] = useState(""); const [dateYear, setDateYear] = useState("");
// Inline edit-event form.
const [editId, setEditId] = useState<string | null>(null);
const [edType, setEdType] = useState("birth");
const [edTypeOther, setEdTypeOther] = useState("");
const [edQual, setEdQual] = useState("exact");
const [edDay, setEdDay] = useState("");
const [edMonth, setEdMonth] = useState("");
const [edYear, setEdYear] = useState("");
// Inline edit-person form (name + vitals).
const [editingPerson, setEditingPerson] = useState(false);
const [pGiven, setPGiven] = useState("");
const [pSurname, setPSurname] = useState("");
const [pGender, setPGender] = useState("");
const [pLiving, setPLiving] = useState("unknown");
const [pPrivacy, setPPrivacy] = useState<"inherit" | "private" | "public">("inherit");
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent"); const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState(""); const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological"); const [relQual, setRelQual] = useState<Qualifier>("biological");
@@ -112,30 +176,16 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id); const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId); const personCites = citations.filter((c) => c.person_id === personId);
function buildDate() {
const year = dateYear.trim();
if (!year || Number.isNaN(Number(year))) {
return { date_value: null, date_start: null, date_precision: null };
}
const m = dateMonth ? Number(dateMonth) : null;
const d = dateDay.trim() ? Number(dateDay) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(year);
const prefix = DATE_QUALS[dateQual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: dateQual,
};
}
async function addEvent(e: React.FormEvent) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const event_type = evType === "other" ? evTypeOther.trim() : evType; const event_type = evType === "other" ? evTypeOther.trim() : evType;
if (!event_type) return; if (!event_type) return;
const { date_value, date_start, date_precision } = buildDate(); const { date_value, date_start, date_precision } = composeDate(
dateQual,
dateDay,
dateMonth,
dateYear,
);
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", { const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body: { event_type, person_id: personId, date_value, date_start, date_precision }, body: { event_type, person_id: personId, date_value, date_start, date_precision },
@@ -156,6 +206,33 @@ export default function PersonDetailPage() {
load(); load();
} }
function startEdit(ev: Event) {
setEditId(ev.id);
const known = EVENT_TYPES.includes(ev.event_type);
setEdType(known ? ev.event_type : "other");
setEdTypeOther(known ? "" : ev.event_type);
const parsed = parseDateValue(ev.date_value);
setEdQual(parsed.qual);
setEdDay(parsed.day);
setEdMonth(parsed.month);
setEdYear(parsed.year);
}
async function saveEdit() {
if (!editId) return;
const event_type = edType === "other" ? edTypeOther.trim() : edType;
if (!event_type) return;
const { date_value, date_start, date_precision } = composeDate(edQual, edDay, edMonth, edYear);
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: editId } },
body: { event_type, date_value, date_start, date_precision },
});
if (!error) {
setEditId(null);
load();
}
}
async function addRel(e: React.FormEvent) { async function addRel(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!relOther) return; if (!relOther) return;
@@ -213,6 +290,33 @@ export default function PersonDetailPage() {
router.push(`/trees/${treeId}`); router.push(`/trees/${treeId}`);
} }
function startEditPerson(current: Person) {
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
setPSurname(t.length > 1 ? t[t.length - 1] : "");
setPGender(current.gender ?? "");
setPLiving(current.is_living === true ? "living" : current.is_living === false ? "deceased" : "unknown");
setPPrivacy((current.privacy as "inherit" | "private" | "public") ?? "inherit");
setEditingPerson(true);
}
async function savePerson() {
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: {
given: pGiven || null,
surname: pSurname || null,
gender: pGender || null,
is_living: pLiving === "living" ? true : pLiving === "deceased" ? false : null,
privacy: pPrivacy,
},
});
if (!error) {
setEditingPerson(false);
load();
}
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>; if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
@@ -316,15 +420,56 @@ export default function PersonDetailPage() {
Back to tree Back to tree
</Link> </Link>
<div className="flex flex-wrap items-center justify-between gap-2"> {editingPerson ? (
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1> <form
<div className="flex items-center gap-3"> onSubmit={(e) => {
{citeControl("p", { person_id: personId }, personCites)} e.preventDefault();
<Button variant="ghost" size="sm" onClick={removePerson}> savePerson();
Delete }}
</Button> className="space-y-3 rounded-lg border border-[var(--border)] p-4"
>
<div className="flex flex-wrap gap-2">
<Input className="w-40" placeholder="Given name" value={pGiven} onChange={(e) => setPGiven(e.target.value)} />
<Input className="w-40" placeholder="Surname" value={pSurname} onChange={(e) => setPSurname(e.target.value)} />
<Input className="w-32" placeholder="Gender" value={pGender} onChange={(e) => setPGender(e.target.value)} />
<select className={fieldCls} value={pLiving} onChange={(e) => setPLiving(e.target.value)}>
<option value="unknown">Status: unknown</option>
<option value="living">Living</option>
<option value="deceased">Deceased</option>
</select>
<select
className={fieldCls}
value={pPrivacy}
onChange={(e) => setPPrivacy(e.target.value as "inherit" | "private" | "public")}
>
<option value="inherit">Privacy: default</option>
<option value="private">Private</option>
<option value="public">Public</option>
</select>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button type="button" onClick={() => setEditingPerson(false)} className="text-xs text-[var(--muted)]">
cancel
</button>
</div>
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
</div>
</div> </div>
</div> )}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -335,26 +480,98 @@ export default function PersonDetailPage() {
<p className="text-sm text-[var(--muted)]">No events yet.</p> <p className="text-sm text-[var(--muted)]">No events yet.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{events.map((ev) => ( {events.map((ev) =>
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm"> editId === ev.id ? (
<span> <li key={ev.id}>
<span className="font-medium capitalize">{ev.event_type}</span> <form
{ev.date_value ? ( onSubmit={(e) => {
<span className="text-[var(--muted)]"> {ev.date_value}</span> e.preventDefault();
) : null} saveEdit();
</span> }}
<span className="flex items-center gap-3"> className="flex flex-wrap items-end gap-2"
{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"
> >
× <select
</button> className={`${fieldCls} capitalize`}
</span> value={edType}
</li> onChange={(e) => setEdType(e.target.value)}
))} >
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
{edType === "other" && (
<Input
className="h-9 w-32"
placeholder="Custom"
value={edTypeOther}
onChange={(e) => setEdTypeOther(e.target.value)}
/>
)}
<select className={fieldCls} value={edQual} onChange={(e) => setEdQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="Day"
value={edDay}
onChange={(e) => setEdDay(e.target.value)}
/>
<select className={fieldCls} value={edMonth} onChange={(e) => setEdMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="Year"
value={edYear}
onChange={(e) => setEdYear(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<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={() => startEdit(ev)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul> </ul>
)} )}
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2"> <form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
+136 -59
View File
@@ -3,14 +3,19 @@
// Vendored from family-chart/dist/styles (the package blocks the CSS subpath export). // Vendored from family-chart/dist/styles (the package blocks the CSS subpath export).
import "./chart.css"; import "./chart.css";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { FanChart } from "@/components/fan-chart";
type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"]; type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"]; type Event = components["schemas"]["EventRead"];
type Mode = "landscape" | "portrait" | "fan";
function splitName(name: string | null | undefined): [string, string] { function splitName(name: string | null | undefined): [string, string] {
const t = (name ?? "").trim().split(/\s+/).filter(Boolean); const t = (name ?? "").trim().split(/\s+/).filter(Boolean);
@@ -23,11 +28,16 @@ export default function TreePage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const treeId = params.id; const treeId = params.id;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading"); const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
const [focusId, setFocusId] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>("landscape");
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const p = await api.GET("/api/v1/trees/{tree_id}/persons", { const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
@@ -40,31 +50,56 @@ export default function TreePage() {
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
]); ]);
const people = p.data ?? []; if (cancelled) return;
const rels: Relationship[] = r.data ?? []; const ppl = p.data ?? [];
const events: Event[] = e.data ?? []; setPeople(ppl);
if (people.length === 0) { setRels(r.data ?? []);
if (!cancelled) setStatus("empty"); setEvents(e.data ?? []);
return; setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
} setStatus(ppl.length ? "ready" : "empty");
})().catch(() => !cancelled && setStatus("error"));
const parentsOf = (id: string) => return () => {
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id); cancelled = true;
const childrenOf = (id: string) => };
rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id); }, [router, treeId]);
const partnersOf = (id: string) =>
rels const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id)) const parentsOf = useCallback(
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)); (id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
const birthYear = new Map<string, string>(); [rels],
for (const ev of events) { );
if (ev.person_id && ev.event_type === "birth" && !birthYear.has(ev.person_id)) { const childrenOf = useCallback(
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? ""; (id: string) =>
if (y) birthYear.set(ev.person_id, y); rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id),
} [rels],
);
const partnersOf = useCallback(
(id: string) =>
rels
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id))
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)),
[rels],
);
const years = useMemo(() => {
const m = new Map<string, string>();
for (const ev of events) {
if (ev.person_id && ev.event_type === "birth" && !m.has(ev.person_id)) {
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
if (y) m.set(ev.person_id, y);
} }
}
return m;
}, [events]);
const nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "Unknown", [byId]);
const yearOf = useCallback((id: string) => years.get(id) ?? "", [years]);
// family-chart for landscape/portrait. Intentionally not keyed on focusId —
// card clicks recenter via updateMainId without rebuilding the chart.
useEffect(() => {
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
let cancelled = false;
(async () => {
const data = people.map((pp) => { const data = people.map((pp) => {
const [fn, ln] = splitName(pp.primary_name); const [fn, ln] = splitName(pp.primary_name);
return { return {
@@ -72,56 +107,98 @@ export default function TreePage() {
data: { data: {
"first name": fn || "Unnamed", "first name": fn || "Unnamed",
"last name": ln, "last name": ln,
birthday: birthYear.get(pp.id) ?? "", birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M", gender: pp.gender === "female" ? "F" : "M",
}, },
rels: { rels: { spouses: partnersOf(pp.id), parents: parentsOf(pp.id), children: childrenOf(pp.id) },
spouses: partnersOf(pp.id),
parents: parentsOf(pp.id),
children: childrenOf(pp.id),
},
}; };
}); });
const f3 = await import("family-chart");
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
try { containerRef.current.innerHTML = "";
const f3 = await import("family-chart"); const chart = f3.createChart(containerRef.current, data);
containerRef.current.innerHTML = ""; chart
const chart = f3.createChart(containerRef.current, data); .setCardHtml()
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]); .setCardDisplay([["first name", "last name"], ["birthday"]])
chart.updateTree({ initial: true }); .setOnCardClick((_e: unknown, d: { data?: { id?: string } }) => {
if (!cancelled) setStatus("ready"); const id = d?.data?.id;
} catch { if (id) {
if (!cancelled) setStatus("error"); setFocusId(id);
} chart.updateMainId(id);
})().catch(() => { chart.updateTree();
if (!cancelled) setStatus("error"); }
}); });
if (mode === "portrait") chart.setOrientationVertical();
else chart.setOrientationHorizontal();
if (focusId) chart.updateMainId(focusId);
chart.updateTree({ initial: true });
})();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [router, treeId]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, mode, people, rels, events]);
const ModeButton = ({ m, label }: { m: Mode; label: string }) => (
<button
onClick={() => setMode(m)}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === m ? "bg-bronze text-paper" : "text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{label}
</button>
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Tree</h1> <h1 className="text-2xl font-semibold">Tree</h1>
<span className="text-sm text-[var(--muted)]"> <div className="flex items-center gap-3">
Drag to pan · scroll to zoom · click a person to recenter <div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
</span> <ModeButton m="landscape" label="Landscape" />
<ModeButton m="portrait" label="Portrait" />
<ModeButton m="fan" label="Fan" />
</div>
{focusId && (
<Link
href={`/trees/${treeId}/persons/${focusId}`}
className="text-sm text-bronze hover:underline"
>
Open {nameOf(focusId)}
</Link>
)}
</div>
</div> </div>
{status === "empty" && ( {status === "empty" && (
<p className="text-[var(--muted)]"> <p className="text-[var(--muted)]">No people yet add some under People, or import a GEDCOM.</p>
No people yet add some under People, or import a GEDCOM.
</p>
)} )}
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>} {status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
<div
ref={containerRef} {status === "ready" && mode === "fan" && focusId ? (
className="f3 rounded-xl border border-[var(--border)]" <div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
style={{ width: "100%", height: "74vh", background: "var(--surface)" }} <FanChart
/> focusId={focusId}
parentsOf={parentsOf}
nameOf={nameOf}
yearOf={yearOf}
onSelect={setFocusId}
/>
</div>
) : (
<div
ref={containerRef}
className="f3 rounded-xl border border-[var(--border)]"
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
/>
)}
<p className="text-sm text-[var(--muted)]">
{mode === "fan"
? "Click an ancestor to recenter the fan."
: "Drag to pan · scroll to zoom · click a person to recenter."}
</p>
</div> </div>
); );
} }
+128
View File
@@ -0,0 +1,128 @@
"use client";
// Radial fan chart of a focus person's ancestors (family-chart has no fan).
// Each generation is a ring; slot p in generation g descends from slot floor(p/2)
// in g-1. Click a wedge to refocus.
type Props = {
focusId: string;
parentsOf: (id: string) => string[];
nameOf: (id: string) => string;
yearOf: (id: string) => string;
onSelect: (id: string) => void;
generations?: number;
};
const SIZE = 720;
const CENTER = SIZE / 2;
const FOCUS_R = 46;
const SPAN = Math.PI * 1.6; // 288° fan
function polar(r: number, a: number): [number, number] {
// a = 0 points up, increasing clockwise.
return [CENTER + r * Math.sin(a), CENTER - r * Math.cos(a)];
}
function sector(r0: number, r1: number, a0: number, a1: number): string {
const [x0, y0] = polar(r1, a0);
const [x1, y1] = polar(r1, a1);
const [x2, y2] = polar(r0, a1);
const [x3, y3] = polar(r0, a0);
const large = a1 - a0 > Math.PI ? 1 : 0;
return `M${x0} ${y0} A${r1} ${r1} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r0} ${r0} 0 ${large} 0 ${x3} ${y3} Z`;
}
function clip(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}
export function FanChart({
focusId,
parentsOf,
nameOf,
yearOf,
onSelect,
generations = 4,
}: Props) {
const gens: (string | null)[][] = [[focusId]];
for (let g = 1; g <= generations; g++) {
const row: (string | null)[] = [];
for (const slot of gens[g - 1]) {
const ps = slot ? parentsOf(slot) : [];
row.push(ps[0] ?? null, ps[1] ?? null);
}
gens.push(row);
}
const ringT = (CENTER - 60 - FOCUS_R) / generations;
const start = -SPAN / 2;
const wedges: React.ReactNode[] = [];
for (let g = 1; g <= generations; g++) {
const row = gens[g];
const w = SPAN / row.length;
const r0 = FOCUS_R + (g - 1) * ringT;
const r1 = FOCUS_R + g * ringT;
row.forEach((id, i) => {
const a0 = start + i * w;
const a1 = start + (i + 1) * w;
const mid = (a0 + a1) / 2;
const [tx, ty] = polar((r0 + r1) / 2, mid);
let deg = (mid * 180) / Math.PI;
if (deg > 90 || deg < -90) deg += 180; // keep text upright
wedges.push(
<g
key={`${g}-${i}`}
onClick={() => id && onSelect(id)}
style={{ cursor: id ? "pointer" : "default" }}
>
<path
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
fill={id ? "var(--surface)" : "transparent"}
stroke="var(--border)"
/>
{id && (
<text
x={tx}
y={ty}
transform={`rotate(${deg} ${tx} ${ty})`}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: g >= 3 ? 9 : 11, fill: "var(--foreground)" }}
>
{clip(nameOf(id), g >= 3 ? 12 : 18)}
</text>
)}
</g>,
);
});
}
const [fx, fy] = [CENTER, CENTER];
return (
<div className="overflow-auto">
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} className="mx-auto block w-full max-w-3xl">
{wedges}
<circle cx={fx} cy={fy} r={FOCUS_R} fill="var(--color-bronze)" />
<text
x={fx}
y={fy - 4}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: 12, fill: "var(--color-paper)", fontWeight: 600 }}
>
{clip(nameOf(focusId), 12)}
</text>
<text
x={fx}
y={fy + 12}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: 10, fill: "var(--color-paper)" }}
>
{yearOf(focusId)}
</text>
</svg>
</div>
);
}
+112 -2
View File
@@ -243,7 +243,8 @@ export interface paths {
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"]; delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Person */
patch: operations["update_person_api_v1_trees__tree_id__persons__person_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": { "/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
@@ -312,7 +313,8 @@ export interface paths {
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"]; delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Event */
patch: operations["update_event_api_v1_trees__tree_id__events__event_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/relationships": { "/api/v1/trees/{tree_id}/relationships": {
@@ -676,6 +678,27 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** EventUpdate */
EventUpdate: {
/** Event Type */
event_type?: 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 | null;
/** Detail */
detail?: string | null;
/** Notes */
notes?: string | null;
};
/** HTTPValidationError */ /** HTTPValidationError */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -798,6 +821,20 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** PersonUpdate */
PersonUpdate: {
/** Given */
given?: string | null;
/** Surname */
surname?: string | null;
/** Gender */
gender?: string | null;
/** Is Living */
is_living?: boolean | null;
privacy?: components["schemas"]["PersonPrivacy"] | null;
/** Notes */
notes?: string | null;
};
/** RegisterRequest */ /** RegisterRequest */
RegisterRequest: { RegisterRequest: {
/** Email */ /** Email */
@@ -1412,6 +1449,7 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
deleted?: boolean; deleted?: boolean;
q?: string | null;
}; };
header?: never; header?: never;
path: { path: {
@@ -1538,6 +1576,42 @@ export interface operations {
}; };
}; };
}; };
update_person_api_v1_trees__tree_id__persons__person_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PersonUpdate"];
};
};
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"];
};
};
};
};
restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: { restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1698,6 +1772,42 @@ export interface operations {
}; };
}; };
}; };
update_event_api_v1_trees__tree_id__events__event_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
event_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EventUpdate"];
};
};
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"];
};
};
};
};
list_relationships_api_v1_trees__tree_id__relationships_get: { list_relationships_api_v1_trees__tree_id__relationships_get: {
parameters: { parameters: {
query?: never; query?: never;
+317
View File
@@ -564,6 +564,22 @@
"default": false, "default": false,
"title": "Deleted" "title": "Deleted"
} }
},
{
"name": "q",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Q"
}
} }
], ],
"responses": { "responses": {
@@ -595,6 +611,67 @@
} }
}, },
"/api/v1/trees/{tree_id}/persons/{person_id}": { "/api/v1/trees/{tree_id}/persons/{person_id}": {
"patch": {
"tags": [
"persons"
],
"summary": "Update Person",
"operationId": "update_person_api_v1_trees__tree_id__persons__person_id__patch",
"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"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonUpdate"
}
}
}
},
"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"
}
}
}
}
}
},
"delete": { "delete": {
"tags": [ "tags": [
"persons" "persons"
@@ -900,6 +977,67 @@
} }
}, },
"/api/v1/trees/{tree_id}/events/{event_id}": { "/api/v1/trees/{tree_id}/events/{event_id}": {
"patch": {
"tags": [
"events"
],
"summary": "Update Event",
"operationId": "update_event_api_v1_trees__tree_id__events__event_id__patch",
"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"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EventUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EventRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": { "delete": {
"tags": [ "tags": [
"events" "events"
@@ -2345,6 +2483,114 @@
], ],
"title": "EventRead" "title": "EventRead"
}, },
"EventUpdate": {
"properties": {
"event_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Event Type"
},
"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": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Calendar"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Notes"
}
},
"type": "object",
"title": "EventUpdate"
},
"HTTPValidationError": { "HTTPValidationError": {
"properties": { "properties": {
"detail": { "detail": {
@@ -2693,6 +2939,77 @@
], ],
"title": "PersonRead" "title": "PersonRead"
}, },
"PersonUpdate": {
"properties": {
"given": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Given"
},
"surname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Surname"
},
"gender": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Gender"
},
"is_living": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Is Living"
},
"privacy": {
"anyOf": [
{
"$ref": "#/components/schemas/PersonPrivacy"
},
{
"type": "null"
}
]
},
"notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Notes"
}
},
"type": "object",
"title": "PersonUpdate"
},
"RegisterRequest": { "RegisterRequest": {
"properties": { "properties": {
"email": { "email": {