From 76b7f453c1a0ddffa9610e416d3a60eeca251b0b Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sun, 7 Jun 2026 09:35:55 -0400 Subject: [PATCH 1/2] 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( -- 2.52.0 From ab064bce6e3b5549f31a71e3850425fc9d294550 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sun, 7 Jun 2026 09:35:55 -0400 Subject: [PATCH 2/2] Edit UI for people and life events; existing-person picker in family view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Paul --- frontend/app/trees/[id]/page.tsx | 92 ++++-- .../trees/[id]/persons/[personId]/page.tsx | 311 +++++++++++++++--- frontend/lib/api/schema.d.ts | 113 ++++++- frontend/openapi.json | 301 +++++++++++++++++ 4 files changed, 741 insertions(+), 76 deletions(-) diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index f607b20..e4f75e6 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -122,23 +122,42 @@ export default function FamilyViewPage() { 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) { e.preventDefault(); if (!adding || !addName.trim()) return; const newId = await addPerson(addName); - if (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, - }); - } + if (newId) await createLink(adding.kind, adding.anchor, newId); setAdding(null); setAddName(""); load(); @@ -210,26 +229,45 @@ export default function FamilyViewPage() { label: string; }) => adding?.key === formKey ? ( -
+ setAddName(e.target.value)} /> -
- - -
+ {addName.trim() && ( +
+ {people + .filter( + (p) => + p.id !== anchor && + (p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()), + ) + .slice(0, 6) + .map((p) => ( + + ))} + +
+ )} +
) : ( + {editingPerson ? ( +
{ + e.preventDefault(); + savePerson(); + }} + className="space-y-3 rounded-lg border border-[var(--border)] p-4" + > +
+ setPGiven(e.target.value)} /> + setPSurname(e.target.value)} /> + setPGender(e.target.value)} /> + + +
+
+ + +
+
+ ) : ( +
+

{person.primary_name ?? "Unnamed person"}

+
+ {citeControl("p", { person_id: personId }, personCites)} + + +
- + )} @@ -335,26 +480,98 @@ export default function PersonDetailPage() {

No events yet.

) : (
    - {events.map((ev) => ( -
  • - - {ev.event_type} - {ev.date_value ? ( - — {ev.date_value} - ) : null} - - - {citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))} - - -
  • - ))} + + {edType === "other" && ( + setEdTypeOther(e.target.value)} + /> + )} + + setEdDay(e.target.value)} + /> + + setEdYear(e.target.value)} + /> + + + + + ) : ( +
  • + + {ev.event_type} + {ev.date_value ? ( + — {ev.date_value} + ) : null} + + + {citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))} + + + +
  • + ), + )}
)}
diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index 787e9ea..f6b65f3 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -243,7 +243,8 @@ export interface paths { delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"]; options?: never; head?: never; - patch?: never; + /** Update Person */ + patch: operations["update_person_api_v1_trees__tree_id__persons__person_id__patch"]; trace?: never; }; "/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"]; options?: never; head?: never; - patch?: never; + /** Update Event */ + patch: operations["update_event_api_v1_trees__tree_id__events__event_id__patch"]; trace?: never; }; "/api/v1/trees/{tree_id}/relationships": { @@ -676,6 +678,27 @@ export interface components { */ 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: { /** Detail */ @@ -798,6 +821,20 @@ export interface components { */ 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: { /** Email */ @@ -1539,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: { parameters: { query?: never; @@ -1699,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: { parameters: { query?: never; diff --git a/frontend/openapi.json b/frontend/openapi.json index 358ff5f..c897474 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -611,6 +611,67 @@ } }, "/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": { "tags": [ "persons" @@ -916,6 +977,67 @@ } }, "/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": { "tags": [ "events" @@ -2361,6 +2483,114 @@ ], "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": { "properties": { "detail": { @@ -2709,6 +2939,77 @@ ], "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": { "properties": { "email": { -- 2.52.0