From 76b7f453c1a0ddffa9610e416d3a60eeca251b0b Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sun, 7 Jun 2026 09:35:55 -0400 Subject: [PATCH] Add update (CRUD) for events and people; record the full-CRUD invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Paul --- CLAUDE.md | 1 + backend/app/api/v1/events.py | 21 +++++++++- backend/app/api/v1/persons.py | 21 +++++++++- backend/app/schemas/event.py | 13 +++++++ backend/app/schemas/person.py | 10 +++++ backend/app/services/event_service.py | 38 ++++++++++++++++++ backend/app/services/person_service.py | 53 ++++++++++++++++++++++++++ backend/tests/test_core_api.py | 19 +++++++++ backend/tests/test_graph.py | 19 +++++++++ 9 files changed, 193 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5484507..0619e29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. 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. +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 diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 928cfd3..2312515 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, status 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 router = APIRouter(prefix="/trees", tags=["events"]) @@ -40,6 +40,25 @@ async def list_person_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) async def delete_event( tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/api/v1/persons.py b/backend/app/api/v1/persons.py index 9beb92c..6d23240 100644 --- a/backend/app/api/v1/persons.py +++ b/backend/app/api/v1/persons.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, status 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 # 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] +@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) async def delete_person( tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index 9f6bd3d..0b6d242 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -20,6 +20,19 @@ class EventCreate(BaseModel): 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): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index 55eb3f3..f8e95c0 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -15,6 +15,16 @@ class PersonCreate(BaseModel): 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): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/event_service.py b/backend/app/services/event_service.py index ebde8a8..451dbff 100644 --- a/backend/app/services/event_service.py +++ b/backend/app/services/event_service.py @@ -122,6 +122,44 @@ async def list_events_for_person( 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( session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID ) -> None: diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py index 03e82c0..ad01a3f 100644 --- a/backend/app/services/person_service.py +++ b/backend/app/services/person_service.py @@ -95,6 +95,59 @@ async def create_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( session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID ) -> Person: diff --git a/backend/tests/test_core_api.py b/backend/tests/test_core_api.py index c874feb..7ffef6f 100644 --- a/backend/tests/test_core_api.py +++ b/backend/tests/test_core_api.py @@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client): 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): resp = await client.get("/api/v1/trees") assert resp.status_code == 401 diff --git a/backend/tests/test_graph.py b/backend/tests/test_graph.py index 9af83a0..cee33f3 100644 --- a/backend/tests/test_graph.py +++ b/backend/tests/test_graph.py @@ -48,6 +48,25 @@ async def test_event_create_list_delete(client): 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): h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com") resp = await client.post(