From 04ccdbf96a023e7024b4f2e1c73d52c29ed5a2a5 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sun, 7 Jun 2026 10:21:12 -0400 Subject: [PATCH] Alternate names (maiden/married), self-person link, deletion integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names (the genealogy standard: maiden name primary, married/alias as typed alternates): - Name model already supported multiple typed names; expose full CRUD — NameCreate/Read/Update schemas, name_service (one-primary invariant, promote-on-delete), nested /persons/{id}/names routes. - Person page gains a Names card: add/edit/delete + "make primary", with a curated name_type dropdown (birth/maiden, married, alias, nickname, …). Self-person ("who am I"): - users.self_person_id FK (use_alter for the users<->persons<->trees cycle) + migration; PATCH /users/me/self-person; "This is me" / "This is you" on the person page. Soft-deleting the linked person clears it. Deletion integrity (fixes the broken tree view): - delete_person now soft-deletes the relationships touching the person, so no dangling edges remain; family-chart also filters links to missing people. - Optional cascade=true recursively deletes descendants (GEDCOM cleanup); the person page asks "only this person" vs "with all descendants". - DELETE returns {deleted: n}. Family view surfaces "Not connected to anyone" so dangling people aren't lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/v1/__init__.py | 2 + backend/app/api/v1/names.py | 90 +++ backend/app/api/v1/persons.py | 17 +- backend/app/api/v1/users.py | 16 +- backend/app/models/user.py | 15 +- backend/app/schemas/name.py | 42 ++ backend/app/schemas/user.py | 6 + backend/app/services/name_service.py | 208 ++++++ backend/app/services/person_service.py | 114 +++- backend/app/services/user_service.py | 41 +- .../versions/b3d5f8a1c920_user_self_person.py | 36 ++ backend/tests/test_delete_and_self.py | 83 +++ backend/tests/test_names.py | 92 +++ backend/tests/test_recovery.py | 2 +- frontend/app/trees/[id]/page.tsx | 45 ++ .../trees/[id]/persons/[personId]/page.tsx | 284 +++++++- frontend/app/trees/[id]/tree/page.tsx | 11 +- frontend/lib/api/schema.d.ts | 329 +++++++++- frontend/openapi.json | 604 +++++++++++++++++- 19 files changed, 2004 insertions(+), 33 deletions(-) create mode 100644 backend/app/api/v1/names.py create mode 100644 backend/app/schemas/name.py create mode 100644 backend/app/services/name_service.py create mode 100644 backend/migrations/versions/b3d5f8a1c920_user_self_person.py create mode 100644 backend/tests/test_delete_and_self.py create mode 100644 backend/tests/test_names.py diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 54e736e..d11ded5 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -8,6 +8,7 @@ from app.api.v1 import ( events, gedcom, media, + names, persons, relationships, sources, @@ -20,6 +21,7 @@ api_router.include_router(auth.router) api_router.include_router(users.router) api_router.include_router(trees.router) api_router.include_router(persons.router) +api_router.include_router(names.router) api_router.include_router(events.router) api_router.include_router(relationships.router) api_router.include_router(sources.router) diff --git a/backend/app/api/v1/names.py b/backend/app/api/v1/names.py new file mode 100644 index 0000000..65afda0 --- /dev/null +++ b/backend/app/api/v1/names.py @@ -0,0 +1,90 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.name import NameCreate, NameRead, NameUpdate +from app.services import name_service, tree_service + +# Names are nested under their person (which is nested under the tree tenant). +router = APIRouter(prefix="/trees", tags=["names"]) + + +@router.get("/{tree_id}/persons/{person_id}/names", response_model=list[NameRead]) +async def list_names( + tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[NameRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + names = await name_service.list_names( + session, viewer_id=current.id, tree=tree, person_id=person_id + ) + return [NameRead.model_validate(n) for n in names] + + +@router.post( + "/{tree_id}/persons/{person_id}/names", + response_model=NameRead, + status_code=status.HTTP_201_CREATED, +) +async def create_name( + tree_id: uuid.UUID, + person_id: uuid.UUID, + data: NameCreate, + session: SessionDep, + current: CurrentUser, +) -> NameRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + name = await name_service.create_name( + session, + actor=current, + tree=tree, + person_id=person_id, + name_type=data.name_type, + given=data.given, + surname=data.surname, + prefix=data.prefix, + suffix=data.suffix, + nickname=data.nickname, + is_primary=data.is_primary, + ) + return NameRead.model_validate(name) + + +@router.patch( + "/{tree_id}/persons/{person_id}/names/{name_id}", response_model=NameRead +) +async def update_name( + tree_id: uuid.UUID, + person_id: uuid.UUID, + name_id: uuid.UUID, + data: NameUpdate, + session: SessionDep, + current: CurrentUser, +) -> NameRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + name = await name_service.update_name( + session, + actor=current, + tree=tree, + person_id=person_id, + name_id=name_id, + changes=data.model_dump(exclude_unset=True), + ) + return NameRead.model_validate(name) + + +@router.delete( + "/{tree_id}/persons/{person_id}/names/{name_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_name( + tree_id: uuid.UUID, + person_id: uuid.UUID, + name_id: uuid.UUID, + session: SessionDep, + current: CurrentUser, +) -> None: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + await name_service.delete_name( + session, actor=current, tree=tree, person_id=person_id, name_id=name_id + ) diff --git a/backend/app/api/v1/persons.py b/backend/app/api/v1/persons.py index 6d23240..7641cc3 100644 --- a/backend/app/api/v1/persons.py +++ b/backend/app/api/v1/persons.py @@ -75,12 +75,21 @@ async def update_person( 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}") async def delete_person( - tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser -) -> None: + tree_id: uuid.UUID, + person_id: uuid.UUID, + session: SessionDep, + current: CurrentUser, + cascade: bool = False, +) -> dict[str, int]: + """Delete a person. ``cascade=true`` also deletes all descendants. Returns + the number of persons deleted (1 unless cascading).""" tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) - await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id) + deleted = await person_service.delete_person( + session, actor=current, tree=tree, person_id=person_id, cascade=cascade + ) + return {"deleted": deleted} @router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index af0f451..58be4fe 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from app.api.deps import CurrentUser -from app.schemas.user import UserRead +from app.api.deps import CurrentUser, SessionDep +from app.schemas.user import UserRead, UserSelfPersonUpdate +from app.services import user_service router = APIRouter(prefix="/users", tags=["users"]) @@ -9,3 +10,14 @@ router = APIRouter(prefix="/users", tags=["users"]) @router.get("/me", response_model=UserRead) async def read_me(current: CurrentUser) -> UserRead: return UserRead.model_validate(current) + + +@router.patch("/me/self-person", response_model=UserRead) +async def set_self_person( + data: UserSelfPersonUpdate, session: SessionDep, current: CurrentUser +) -> UserRead: + """Link (or unlink) the Person record that represents this account.""" + user = await user_service.set_self_person( + session, user=current, person_id=data.self_person_id + ) + return UserRead.model_validate(user) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 16f70b7..444e34d 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -3,9 +3,10 @@ multiple auth providers later (the provider-link table arrives with the auth slice). ``hashed_password`` is nullable: external/OIDC users have none. """ +import uuid from datetime import datetime -from sqlalchemy import DateTime, String +from sqlalchemy import DateTime, ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base @@ -19,3 +20,15 @@ class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete): email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) display_name: Mapped[str | None] = mapped_column(String(255)) hashed_password: Mapped[str | None] = mapped_column(String(255)) + # The Person record that *is* this user ("home person"). Cleared if that + # person is deleted, so the link can never dangle. + self_person_id: Mapped[uuid.UUID | None] = mapped_column( + # use_alter + explicit name: users<->persons<->trees form an FK cycle, + # so this constraint must be created/dropped via ALTER, not inline. + ForeignKey( + "persons.id", + ondelete="SET NULL", + name="fk_users_self_person_id", + use_alter=True, + ) + ) diff --git a/backend/app/schemas/name.py b/backend/app/schemas/name.py new file mode 100644 index 0000000..11a832d --- /dev/null +++ b/backend/app/schemas/name.py @@ -0,0 +1,42 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class NameCreate(BaseModel): + # Open vocabulary: birth/maiden, married, alias, religious, nickname, ... + name_type: str = "birth" + given: str | None = None + surname: str | None = None + prefix: str | None = None + suffix: str | None = None + nickname: str | None = None + is_primary: bool = False + + +class NameUpdate(BaseModel): + name_type: str | None = None + given: str | None = None + surname: str | None = None + prefix: str | None = None + suffix: str | None = None + nickname: str | None = None + is_primary: bool | None = None + + +class NameRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tree_id: uuid.UUID + person_id: uuid.UUID + name_type: str + given: str | None + surname: str | None + prefix: str | None + suffix: str | None + nickname: str | None + is_primary: bool + sort_order: int + created_at: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 0535f73..f3f9e91 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -19,4 +19,10 @@ class UserRead(BaseModel): email: str display_name: str | None email_verified_at: datetime | None + self_person_id: uuid.UUID | None = None created_at: datetime + + +class UserSelfPersonUpdate(BaseModel): + # null clears the link; otherwise the Person that represents this account. + self_person_id: uuid.UUID | None = None diff --git a/backend/app/services/name_service.py b/backend/app/services/name_service.py new file mode 100644 index 0000000..60a48f5 --- /dev/null +++ b/backend/app/services/name_service.py @@ -0,0 +1,208 @@ +"""Name service. A Person carries one or more Name rows — a primary (typically +the birth/maiden name) plus typed alternates (married, alias, religious, …). +Exactly one name is primary at a time; it drives display everywhere. Writes +require editor rights; reads go through the tree's view check. +""" + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.person import Name, Person +from app.models.tree import Tree +from app.models.user import User +from app.services import privacy +from app.services.audit import record_audit +from app.services.exceptions import Forbidden, NotFound + + +async def _get_person(session: AsyncSession, *, tree: Tree, person_id: uuid.UUID) -> Person: + 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") + return person + + +async def _clear_primary( + session: AsyncSession, *, person_id: uuid.UUID, keep: uuid.UUID | None +) -> None: + """Demote every other name so exactly one stays primary.""" + stmt = ( + update(Name) + .where(Name.person_id == person_id, Name.deleted_at.is_(None), Name.is_primary.is_(True)) + .values(is_primary=False) + ) + if keep is not None: + stmt = stmt.where(Name.id != keep) + await session.execute(stmt) + + +async def list_names( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID +) -> list[Name]: + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + await _get_person(session, tree=tree, person_id=person_id) + stmt = ( + select(Name) + .where(Name.person_id == person_id, Name.deleted_at.is_(None)) + .order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def create_name( + session: AsyncSession, + *, + actor: User, + tree: Tree, + person_id: uuid.UUID, + name_type: str = "birth", + given: str | None = None, + surname: str | None = None, + prefix: str | None = None, + suffix: str | None = None, + nickname: str | None = None, + is_primary: bool = False, +) -> Name: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + await _get_person(session, tree=tree, person_id=person_id) + + # First name for a person is always primary; otherwise honor the flag. + existing = ( + await session.execute( + select(Name.id).where(Name.person_id == person_id, Name.deleted_at.is_(None)) + ) + ).first() + primary = is_primary or existing is None + if primary: + await _clear_primary(session, person_id=person_id, keep=None) + + name = Name( + tree_id=tree.id, + person_id=person_id, + name_type=name_type, + given=given, + surname=surname, + prefix=prefix, + suffix=suffix, + nickname=nickname, + is_primary=primary, + ) + session.add(name) + await session.flush() + record_audit( + session, + action="create", + entity_type="Name", + entity_id=name.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"name_type": name_type, "given": given, "surname": surname}, + ) + await session.commit() + await session.refresh(name) + return name + + +_NAME_FIELDS = {"name_type", "given", "surname", "prefix", "suffix", "nickname"} + + +async def update_name( + session: AsyncSession, + *, + actor: User, + tree: Tree, + person_id: uuid.UUID, + name_id: uuid.UUID, + changes: dict, +) -> Name: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + name = ( + await session.execute( + select(Name).where( + Name.id == name_id, + Name.person_id == person_id, + Name.tree_id == tree.id, + Name.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + if name is None: + raise NotFound("name not found") + + for key in _NAME_FIELDS & changes.keys(): + setattr(name, key, changes[key]) + if changes.get("is_primary") is True: + await _clear_primary(session, person_id=person_id, keep=name.id) + name.is_primary = True + + record_audit( + session, + action="update", + entity_type="Name", + entity_id=name.id, + tree_id=tree.id, + actor_user_id=actor.id, + after=changes, + ) + await session.commit() + await session.refresh(name) + return name + + +async def delete_name( + session: AsyncSession, + *, + actor: User, + tree: Tree, + person_id: uuid.UUID, + name_id: uuid.UUID, +) -> None: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + name = ( + await session.execute( + select(Name).where( + Name.id == name_id, + Name.person_id == person_id, + Name.tree_id == tree.id, + Name.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + if name is None: + raise NotFound("name not found") + name.deleted_at = datetime.now(UTC) + was_primary = name.is_primary + name.is_primary = False + record_audit( + session, + action="delete", + entity_type="Name", + entity_id=name.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + # Promote another name to primary so the person never loses their display name. + if was_primary: + nxt = ( + await session.execute( + select(Name) + .where(Name.person_id == person_id, Name.deleted_at.is_(None)) + .order_by(Name.sort_order, Name.created_at) + ) + ).scalars().first() + if nxt is not None: + nxt.is_primary = True + await session.commit() diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py index ad01a3f..2482a1c 100644 --- a/backend/app/services/person_service.py +++ b/backend/app/services/person_service.py @@ -6,11 +6,12 @@ person through the privacy engine. Each returned Person gets a transient import uuid from datetime import UTC, datetime -from sqlalchemy import func, or_, select +from sqlalchemy import func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.models.enums import PersonPrivacy +from app.models.enums import PersonPrivacy, RelationshipType from app.models.person import Name, Person +from app.models.relationship import Relationship from app.models.tree import Tree from app.models.user import User from app.services import privacy @@ -177,9 +178,65 @@ async def get_person( return person -async def delete_person( - session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID +async def _children_of( + session: AsyncSession, *, tree_id: uuid.UUID, parent_id: uuid.UUID +) -> list[uuid.UUID]: + rows = ( + await session.execute( + select(Relationship.person_to_id).where( + Relationship.tree_id == tree_id, + Relationship.deleted_at.is_(None), + Relationship.type == RelationshipType.parent_child, + Relationship.person_from_id == parent_id, + ) + ) + ).scalars().all() + return list(rows) + + +async def _soft_delete_one( + session: AsyncSession, *, actor: User, tree: Tree, person: Person, now: datetime ) -> None: + """Soft-delete a single person and the relationships touching them, so no + dangling edges are left to break the tree view.""" + person.deleted_at = now + rels = ( + await session.execute( + select(Relationship).where( + Relationship.tree_id == tree.id, + Relationship.deleted_at.is_(None), + or_( + Relationship.person_from_id == person.id, + Relationship.person_to_id == person.id, + ), + ) + ) + ).scalars().all() + for rel in rels: + rel.deleted_at = now + record_audit( + session, + action="delete", + entity_type="Person", + entity_id=person.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"cascaded_relationships": len(rels)}, + ) + + +async def delete_person( + session: AsyncSession, + *, + actor: User, + tree: Tree, + person_id: uuid.UUID, + cascade: bool = False, +) -> int: + """Soft-delete a person. Always removes the relationships that touch them + (preventing dangling edges). With ``cascade=True``, recursively deletes + their descendants too — handy for pruning a bad GEDCOM import. Returns the + number of persons deleted.""" if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): raise Forbidden("not an editor of this tree") person = ( @@ -191,16 +248,49 @@ async def delete_person( ).scalar_one_or_none() if person is None: raise NotFound("person not found") - person.deleted_at = datetime.now(UTC) - record_audit( - session, - action="delete", - entity_type="Person", - entity_id=person.id, - tree_id=tree.id, - actor_user_id=actor.id, + + now = datetime.now(UTC) + + # Gather the set of persons to delete. For cascade, walk descendants + # breadth-first, guarding against cycles. + to_delete: list[Person] = [person] + if cascade: + seen = {person.id} + frontier = [person.id] + while frontier: + nxt: list[uuid.UUID] = [] + for pid in frontier: + for child_id in await _children_of(session, tree_id=tree.id, parent_id=pid): + if child_id not in seen: + seen.add(child_id) + nxt.append(child_id) + frontier = nxt + extra_ids = [pid for pid in seen if pid != person.id] + if extra_ids: + extra = ( + await session.execute( + select(Person).where( + Person.id.in_(extra_ids), + Person.tree_id == tree.id, + Person.deleted_at.is_(None), + ) + ) + ).scalars().all() + to_delete.extend(extra) + + for p in to_delete: + await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now) + + # Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL" + # never fires — clear any account's self-person link to a deleted person. + await session.execute( + update(User) + .where(User.self_person_id.in_([p.id for p in to_delete])) + .values(self_person_id=None) ) + await session.commit() + return len(to_delete) async def restore_person( diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 61c4498..e2f8ad8 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -8,10 +8,13 @@ import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.models.person import Person +from app.models.tree import Tree from app.models.user import User from app.repositories.base import BaseRepository +from app.services import privacy from app.services.audit import record_audit -from app.services.exceptions import Conflict +from app.services.exceptions import Conflict, Forbidden, NotFound async def create_user( @@ -42,3 +45,39 @@ async def create_user( async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None: return await BaseRepository(session, User).get(user_id) + + +async def set_self_person( + session: AsyncSession, *, user: User, person_id: uuid.UUID | None +) -> User: + """Point a user's account at the Person record that *is* them ("home + person"), or clear it with ``None``. The person must live in a tree the + user can view.""" + if person_id is not None: + person = ( + await session.execute( + select(Person).where(Person.id == person_id, Person.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + if person is None: + raise NotFound("person not found") + tree = ( + await session.execute(select(Tree).where(Tree.id == person.tree_id)) + ).scalar_one_or_none() + if tree is None or not await privacy.can_view_tree( + session, user_id=user.id, tree=tree + ): + raise Forbidden("not permitted to link this person") + + user.self_person_id = person_id + record_audit( + session, + action="update", + entity_type="User", + entity_id=user.id, + actor_user_id=user.id, + after={"self_person_id": str(person_id) if person_id else None}, + ) + await session.commit() + await session.refresh(user) + return user diff --git a/backend/migrations/versions/b3d5f8a1c920_user_self_person.py b/backend/migrations/versions/b3d5f8a1c920_user_self_person.py new file mode 100644 index 0000000..07d7da6 --- /dev/null +++ b/backend/migrations/versions/b3d5f8a1c920_user_self_person.py @@ -0,0 +1,36 @@ +"""user.self_person_id ("home person" link) + +Revision ID: b3d5f8a1c920 +Revises: 9a2b1c7d4e10 +Create Date: 2026-06-07 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "b3d5f8a1c920" +down_revision: str | None = "9a2b1c7d4e10" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("self_person_id", sa.Uuid(), nullable=True), + ) + op.create_foreign_key( + "fk_users_self_person_id", + "users", + "persons", + ["self_person_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_users_self_person_id", "users", type_="foreignkey") + op.drop_column("users", "self_person_id") diff --git a/backend/tests/test_delete_and_self.py b/backend/tests/test_delete_and_self.py new file mode 100644 index 0000000..5f7f6c6 --- /dev/null +++ b/backend/tests/test_delete_and_self.py @@ -0,0 +1,83 @@ +"""Deletion integrity (relationship cleanup + cascade) and the self-person link.""" + +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 _person(client, h, tid, given): + return ( + await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h) + ).json()["id"] + + +async def _link_parent(client, h, tid, parent, child): + await client.post( + f"/api/v1/trees/{tid}/relationships", + json={"type": "parent_child", "person_from_id": parent, "person_to_id": child}, + headers=h, + ) + + +async def test_delete_removes_relationships(client): + h, tid = await _setup(client, "d-rels@example.com") + gp = await _person(client, h, tid, "Grandpa") + dad = await _person(client, h, tid, "Dad") + await _link_parent(client, h, tid, gp, dad) + + r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}", headers=h) + assert r.status_code == 200 and r.json()["deleted"] == 1 + + # The dangling edge is gone, so the tree view can't break on it. + rels = ( + await client.get(f"/api/v1/trees/{tid}/relationships", headers=h) + ).json() + assert rels == [] + # Dad survives. + ppl = {p["id"] for p in (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()} + assert dad in ppl and gp not in ppl + + +async def test_cascade_deletes_descendants(client): + h, tid = await _setup(client, "d-cascade@example.com") + gp = await _person(client, h, tid, "Grandpa") + dad = await _person(client, h, tid, "Dad") + kid = await _person(client, h, tid, "Kid") + await _link_parent(client, h, tid, gp, dad) + await _link_parent(client, h, tid, dad, kid) + + r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}?cascade=true", headers=h) + assert r.status_code == 200 and r.json()["deleted"] == 3 + ppl = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json() + assert ppl == [] + + +async def test_self_person_link(client): + h, tid = await _setup(client, "self@example.com") + me = await _person(client, h, tid, "Me") + + r = await client.patch( + "/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h + ) + assert r.status_code == 200 and r.json()["self_person_id"] == me + + # Reflected on /me. + assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] == me + + # Deleting that person clears the link (SET NULL). + await client.delete(f"/api/v1/trees/{tid}/persons/{me}", headers=h) + assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] is None + + +async def test_self_person_clear(client): + h, tid = await _setup(client, "self-clear@example.com") + me = await _person(client, h, tid, "Me") + await client.patch("/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h) + r = await client.patch( + "/api/v1/users/me/self-person", json={"self_person_id": None}, headers=h + ) + assert r.status_code == 200 and r.json()["self_person_id"] is None diff --git a/backend/tests/test_names.py b/backend/tests/test_names.py new file mode 100644 index 0000000..94c246e --- /dev/null +++ b/backend/tests/test_names.py @@ -0,0 +1,92 @@ +"""Multiple typed names per person: maiden (primary) + married/alias alternates.""" + +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"] + pid = ( + await client.post( + f"/api/v1/trees/{tid}/persons", json={"given": "Mary", "surname": "Smith"}, headers=h + ) + ).json()["id"] + return h, tid, pid + + +async def test_create_lists_and_primary(client): + h, tid, pid = await _setup(client, "n-create@example.com") + base = f"/api/v1/trees/{tid}/persons/{pid}/names" + + # The person was created with a primary birth name. + names = (await client.get(base, headers=h)).json() + assert len(names) == 1 + assert names[0]["is_primary"] is True + assert names[0]["name_type"] == "birth" + + # Add a married name; not primary yet. + r = await client.post( + base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h + ) + assert r.status_code == 201 + assert r.json()["is_primary"] is False + + names = (await client.get(base, headers=h)).json() + assert len(names) == 2 + # Primary first. + assert names[0]["surname"] == "Smith" and names[0]["is_primary"] is True + + +async def test_set_primary_demotes_others(client): + h, tid, pid = await _setup(client, "n-primary@example.com") + base = f"/api/v1/trees/{tid}/persons/{pid}/names" + married = ( + await client.post( + base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h + ) + ).json() + + r = await client.patch(f"{base}/{married['id']}", json={"is_primary": True}, headers=h) + assert r.status_code == 200 and r.json()["is_primary"] is True + + names = {n["surname"]: n["is_primary"] for n in (await client.get(base, headers=h)).json()} + assert names == {"Jones": True, "Smith": False} + + # The person's display name now reflects the new primary. + person = ( + await client.get(f"/api/v1/trees/{tid}/persons/{pid}", headers=h) + ).json() + assert person["primary_name"] == "Mary Jones" + + +async def test_update_fields(client): + h, tid, pid = await _setup(client, "n-update@example.com") + base = f"/api/v1/trees/{tid}/persons/{pid}/names" + nid = ( + await client.post(base, json={"name_type": "alias", "given": "Polly"}, headers=h) + ).json()["id"] + r = await client.patch( + f"{base}/{nid}", json={"surname": "Smith", "nickname": "Poll"}, headers=h + ) + assert r.status_code == 200 + assert r.json()["surname"] == "Smith" and r.json()["nickname"] == "Poll" + + +async def test_delete_promotes_new_primary(client): + h, tid, pid = await _setup(client, "n-delete@example.com") + base = f"/api/v1/trees/{tid}/persons/{pid}/names" + alt = ( + await client.post( + base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h + ) + ).json()["id"] + + # Delete the (primary) birth name; the married name should be promoted. + primary = next( + n for n in (await client.get(base, headers=h)).json() if n["is_primary"] + ) + r = await client.delete(f"{base}/{primary['id']}", headers=h) + assert r.status_code == 204 + + names = (await client.get(base, headers=h)).json() + assert len(names) == 1 and names[0]["id"] == alt and names[0]["is_primary"] is True diff --git a/backend/tests/test_recovery.py b/backend/tests/test_recovery.py index ff4a91e..f81a051 100644 --- a/backend/tests/test_recovery.py +++ b/backend/tests/test_recovery.py @@ -41,7 +41,7 @@ async def test_person_delete_and_restore(client): assert ( await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h) - ).status_code == 204 + ).status_code == 200 assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0 deleted = ( await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h) diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index e4f75e6..79ca7b3 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -317,6 +317,17 @@ export default function FamilyViewPage() { const partners = partnersOf(focus.id); const children = childrenOf(focus.id); + // "Dangling" people: not linked to anyone. Common after a GEDCOM import or a + // mistaken delete — surface them so they're not lost in the directory. + const connected = new Set(); + for (const r of rels) { + connected.add(r.person_from_id); + connected.add(r.person_to_id); + } + const unconnected = people + .filter((p) => !connected.has(p.id)) + .sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? "")); + const sorted = [...people].sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""), ); @@ -380,6 +391,40 @@ export default function FamilyViewPage() { + {/* Unconnected people — not linked to anyone in the tree */} + {unconnected.length > 0 && ( + + +
+

+ Not connected to anyone ({unconnected.length}) +

+ + Open one and add a relationship, or delete it. + +
+
+ {unconnected.slice(0, 60).map((p) => ( +
+ + + open + +
+ ))} +
+ {unconnected.length > 60 && ( +

+ Showing 60 of {unconnected.length}. +

+ )} +
+
+ )} + {/* Scrollable, searchable people directory (scales to large trees) */}
diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index ae7964a..27ca51a 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -12,6 +12,8 @@ import { Input } from "@/components/ui/input"; import { PersonCombobox } from "@/components/person-combobox"; type Person = components["schemas"]["PersonRead"]; +type Name = components["schemas"]["NameRead"]; +type Me = components["schemas"]["UserRead"]; type Event = components["schemas"]["EventRead"]; type Relationship = components["schemas"]["RelationshipRead"]; type Qualifier = components["schemas"]["ParentChildQualifier"]; @@ -23,6 +25,21 @@ type CitationCreate = components["schemas"]["CitationCreate"]; const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"; const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"]; +// Typed name vocabulary. "birth" is the maiden/birth name; "married" etc. are +// alternates. The maiden name stays primary by convention (Ancestry/FamilySearch). +const NAME_TYPES: { value: string; label: string }[] = [ + { value: "birth", label: "Birth / maiden" }, + { value: "married", label: "Married" }, + { value: "alias", label: "Also known as" }, + { value: "nickname", label: "Nickname" }, + { value: "religious", label: "Religious" }, + { value: "immigration", label: "Anglicized" }, +]; +const nameTypeLabel = (t: string) => + NAME_TYPES.find((n) => n.value === t)?.label ?? t; +const formatName = (n: Name) => + [n.given, n.surname].filter(Boolean).join(" ") || "—"; + // Curated genealogical event vocabulary (with an escape hatch). const EVENT_TYPES = [ "birth", "death", "marriage", "divorce", "engagement", "baptism", "burial", @@ -89,6 +106,8 @@ export default function PersonDetailPage() { const [person, setPerson] = useState(null); const [people, setPeople] = useState([]); + const [names, setNames] = useState([]); + const [me, setMe] = useState(null); const [events, setEvents] = useState([]); const [rels, setRels] = useState([]); const [sources, setSources] = useState([]); @@ -123,6 +142,18 @@ export default function PersonDetailPage() { const [relOther, setRelOther] = useState(""); const [relQual, setRelQual] = useState("biological"); + // Add-name form + inline edit. + const [nameType, setNameType] = useState("married"); + const [nGiven, setNGiven] = useState(""); + const [nSurname, setNSurname] = useState(""); + const [editNameId, setEditNameId] = useState(null); + const [enType, setEnType] = useState("married"); + const [enGiven, setEnGiven] = useState(""); + const [enSurname, setEnSurname] = useState(""); + + // Delete confirmation (with optional cascade to descendants). + const [confirmingDelete, setConfirmingDelete] = useState(false); + // Inline citation form: which fact is being cited ("p" = person, `e:`). const [citeFor, setCiteFor] = useState(null); const [citeSource, setCiteSource] = useState(""); @@ -137,8 +168,12 @@ export default function PersonDetailPage() { return; } setPerson(p.data ?? null); - const [all, ev, rl, src, cit] = await Promise.all([ + const [all, nm, mine, ev, rl, src, cit] = await Promise.all([ api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }), + api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", { + params: { path: { tree_id: treeId, person_id: personId } }, + }), + api.GET("/api/v1/users/me"), api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", { params: { path: { tree_id: treeId, person_id: personId } }, }), @@ -149,6 +184,8 @@ export default function PersonDetailPage() { api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }), ]); setPeople(all.data ?? []); + setNames(nm.data ?? []); + setMe(mine.data ?? null); setEvents(ev.data ?? []); setRels(rl.data ?? []); setSources(src.data ?? []); @@ -284,13 +321,72 @@ export default function PersonDetailPage() { load(); } - async function removePerson() { + async function removePerson(cascade: boolean) { await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", { - params: { path: { tree_id: treeId, person_id: personId } }, + params: { path: { tree_id: treeId, person_id: personId }, query: { cascade } }, }); router.push(`/trees/${treeId}`); } + async function addName(e: React.FormEvent) { + e.preventDefault(); + if (!nGiven.trim() && !nSurname.trim()) return; + const { error } = await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/names", { + params: { path: { tree_id: treeId, person_id: personId } }, + body: { name_type: nameType, given: nGiven || null, surname: nSurname || null }, + }); + if (!error) { + setNGiven(""); + setNSurname(""); + setNameType("married"); + load(); + } + } + + function startEditName(n: Name) { + setEditNameId(n.id); + setEnType(n.name_type); + setEnGiven(n.given ?? ""); + setEnSurname(n.surname ?? ""); + } + + async function saveName() { + if (!editNameId) return; + const { error } = await api.PATCH( + "/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", + { + params: { path: { tree_id: treeId, person_id: personId, name_id: editNameId } }, + body: { name_type: enType, given: enGiven || null, surname: enSurname || null }, + }, + ); + if (!error) { + setEditNameId(null); + load(); + } + } + + async function makePrimaryName(id: string) { + await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", { + params: { path: { tree_id: treeId, person_id: personId, name_id: id } }, + body: { is_primary: true }, + }); + load(); + } + + async function removeName(id: string) { + await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", { + params: { path: { tree_id: treeId, person_id: personId, name_id: id } }, + }); + load(); + } + + async function setSelf(link: boolean) { + await api.PATCH("/api/v1/users/me/self-person", { + body: { self_person_id: link ? personId : null }, + }); + load(); + } + 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] ?? "")); @@ -321,6 +417,8 @@ export default function PersonDetailPage() { if (!ready) return

Loading…

; if (!person) return

Not found.

; + const isSelf = me?.self_person_id === personId; + // Inline "cite" control: a badge with count, a toggle, and the picker form. function citeControl(key: string, target: Partial, cites: Citation[]) { return ( @@ -463,19 +561,195 @@ export default function PersonDetailPage() { ) : (
-

{person.primary_name ?? "Unnamed person"}

+

+ {person.primary_name ?? "Unnamed person"} + {isSelf && ( + + This is you + + )} +

{citeControl("p", { person_id: personId }, personCites)} + {isSelf ? ( + + ) : ( + + )} -
)} + {confirmingDelete && ( +
+

+ Delete {person.primary_name ?? "this person"}? Their relationships + will be removed too. This can be undone from Recovery. +

+
+ + + +
+
+ )} + + + + Names + + + {names.length === 0 ? ( +

No names yet.

+ ) : ( +
    + {names.map((n) => + editNameId === n.id ? ( +
  • +
    { + e.preventDefault(); + saveName(); + }} + className="flex flex-wrap items-center gap-2" + > + + setEnGiven(e.target.value)} + /> + setEnSurname(e.target.value)} + /> + + +
    +
  • + ) : ( +
  • + + {formatName(n)} + + {nameTypeLabel(n.name_type)} + + {n.is_primary && ( + + primary + + )} + + + {!n.is_primary && ( + + )} + + + +
  • + ), + )} +
+ )} +
+ + + + +
+
+
+ Life events diff --git a/frontend/app/trees/[id]/tree/page.tsx b/frontend/app/trees/[id]/tree/page.tsx index 6db60bf..957d959 100644 --- a/frontend/app/trees/[id]/tree/page.tsx +++ b/frontend/app/trees/[id]/tree/page.tsx @@ -104,6 +104,11 @@ export default function TreePage() { if (status !== "ready" || mode === "fan" || !containerRef.current) return; let cancelled = false; (async () => { + // Only link to people that still exist — a soft-deleted person leaves + // dangling relationship rows, and family-chart breaks on an id with no + // matching datum. Filter them out so a deletion never blanks the tree. + const alive = new Set(people.map((pp) => pp.id)); + const keep = (ids: string[]) => ids.filter((id) => alive.has(id)); const data = people.map((pp) => { const [fn, ln] = splitName(pp.primary_name); return { @@ -114,7 +119,11 @@ export default function TreePage() { birthday: years.get(pp.id) ?? "", gender: pp.gender === "female" ? "F" : "M", }, - rels: { spouses: partnersOf(pp.id), parents: parentsOf(pp.id), children: childrenOf(pp.id) }, + rels: { + spouses: keep(partnersOf(pp.id)), + parents: keep(parentsOf(pp.id)), + children: keep(childrenOf(pp.id)), + }, }; }); const f3 = await import("family-chart"); diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index f9e3013..d841bd4 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -157,6 +157,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/users/me/self-person": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Set Self Person + * @description Link (or unlink) the Person record that represents this account. + */ + patch: operations["set_self_person_api_v1_users_me_self_person_patch"]; + trace?: never; + }; "/api/v1/trees": { parameters: { query?: never; @@ -240,7 +260,11 @@ export interface paths { get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"]; put?: never; post?: never; - /** Delete Person */ + /** + * Delete Person + * @description Delete a person. ``cascade=true`` also deletes all descendants. Returns + * the number of persons deleted (1 unless cascading). + */ delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"]; options?: never; head?: never; @@ -265,6 +289,42 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/trees/{tree_id}/persons/{person_id}/names": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Names */ + get: operations["list_names_api_v1_trees__tree_id__persons__person_id__names_get"]; + put?: never; + /** Create Name */ + post: operations["create_name_api_v1_trees__tree_id__persons__person_id__names_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Name */ + delete: operations["delete_name_api_v1_trees__tree_id__persons__person_id__names__name_id__delete"]; + options?: never; + head?: never; + /** Update Name */ + patch: operations["update_name_api_v1_trees__tree_id__persons__person_id__names__name_id__patch"]; + trace?: never; + }; "/api/v1/trees/{tree_id}/events": { parameters: { query?: never; @@ -780,6 +840,85 @@ export interface components { /** Source Id */ source_id?: string | null; }; + /** NameCreate */ + NameCreate: { + /** + * Name Type + * @default birth + */ + name_type?: string; + /** Given */ + given?: string | null; + /** Surname */ + surname?: string | null; + /** Prefix */ + prefix?: string | null; + /** Suffix */ + suffix?: string | null; + /** Nickname */ + nickname?: string | null; + /** + * Is Primary + * @default false + */ + is_primary?: boolean; + }; + /** NameRead */ + NameRead: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Tree Id + * Format: uuid + */ + tree_id: string; + /** + * Person Id + * Format: uuid + */ + person_id: string; + /** Name Type */ + name_type: string; + /** Given */ + given: string | null; + /** Surname */ + surname: string | null; + /** Prefix */ + prefix: string | null; + /** Suffix */ + suffix: string | null; + /** Nickname */ + nickname: string | null; + /** Is Primary */ + is_primary: boolean; + /** Sort Order */ + sort_order: number; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; + /** NameUpdate */ + NameUpdate: { + /** Name Type */ + name_type?: string | null; + /** Given */ + given?: string | null; + /** Surname */ + surname?: string | null; + /** Prefix */ + prefix?: string | null; + /** Suffix */ + suffix?: string | null; + /** Nickname */ + nickname?: string | null; + /** Is Primary */ + is_primary?: boolean | null; + }; /** * ParentChildQualifier * @description Qualifies a parent_child edge so adoption/donor/blended families are @@ -1074,12 +1213,19 @@ export interface components { display_name: string | null; /** Email Verified At */ email_verified_at: string | null; + /** Self Person Id */ + self_person_id?: string | null; /** * Created At * Format: date-time */ created_at: string; }; + /** UserSelfPersonUpdate */ + UserSelfPersonUpdate: { + /** Self Person Id */ + self_person_id?: string | null; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -1347,6 +1493,39 @@ export interface operations { }; }; }; + set_self_person_api_v1_users_me_self_person_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserSelfPersonUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_my_trees_api_v1_trees_get: { parameters: { query?: { @@ -1640,7 +1819,9 @@ export interface operations { }; delete_person_api_v1_trees__tree_id__persons__person_id__delete: { parameters: { - query?: never; + query?: { + cascade?: boolean; + }; header?: never; path: { tree_id: string; @@ -1651,11 +1832,15 @@ export interface operations { requestBody?: never; responses: { /** @description Successful Response */ - 204: { + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + [key: string]: number; + }; + }; }; /** @description Validation Error */ 422: { @@ -1736,6 +1921,142 @@ export interface operations { }; }; }; + list_names_api_v1_trees__tree_id__persons__person_id__names_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NameRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_name_api_v1_trees__tree_id__persons__person_id__names_post: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NameCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NameRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_name_api_v1_trees__tree_id__persons__person_id__names__name_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + name_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_name_api_v1_trees__tree_id__persons__person_id__names__name_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + person_id: string; + name_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NameUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NameRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_tree_events_api_v1_trees__tree_id__events_get: { parameters: { query?: never; diff --git a/frontend/openapi.json b/frontend/openapi.json index a449db6..102b6df 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -280,6 +280,48 @@ } } }, + "/api/v1/users/me/self-person": { + "patch": { + "tags": [ + "users" + ], + "summary": "Set Self Person", + "description": "Link (or unlink) the Person record that represents this account.", + "operationId": "set_self_person_api_v1_users_me_self_person_patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSelfPersonUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/trees": { "post": { "tags": [ @@ -728,6 +770,7 @@ "persons" ], "summary": "Delete Person", + "description": "Delete a person. ``cascade=true`` also deletes all descendants. Returns\nthe number of persons deleted (1 unless cascading).", "operationId": "delete_person_api_v1_trees__tree_id__persons__person_id__delete", "parameters": [ { @@ -749,11 +792,32 @@ "format": "uuid", "title": "Person Id" } + }, + { + "name": "cascade", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Cascade" + } } ], "responses": { - "204": { - "description": "Successful Response" + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "title": "Response Delete Person Api V1 Trees Tree Id Persons Person Id Delete" + } + } + } }, "422": { "description": "Validation Error", @@ -872,6 +936,251 @@ } } }, + "/api/v1/trees/{tree_id}/persons/{person_id}/names": { + "get": { + "tags": [ + "names" + ], + "summary": "List Names", + "operationId": "list_names_api_v1_trees__tree_id__persons__person_id__names_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "person_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Person Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRead" + }, + "title": "Response List Names Api V1 Trees Tree Id Persons Person Id Names Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "names" + ], + "summary": "Create Name", + "operationId": "create_name_api_v1_trees__tree_id__persons__person_id__names_post", + "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/NameCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NameRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}": { + "patch": { + "tags": [ + "names" + ], + "summary": "Update Name", + "operationId": "update_name_api_v1_trees__tree_id__persons__person_id__names__name_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" + } + }, + { + "name": "name_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Name Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NameUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NameRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "names" + ], + "summary": "Delete Name", + "operationId": "delete_name_api_v1_trees__tree_id__persons__person_id__names__name_id__delete", + "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" + } + }, + { + "name": "name_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Name Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/trees/{tree_id}/events": { "post": { "tags": [ @@ -3140,6 +3449,267 @@ "type": "object", "title": "MediaUpdate" }, + "NameCreate": { + "properties": { + "name_type": { + "type": "string", + "title": "Name Type", + "default": "birth" + }, + "given": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Given" + }, + "surname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Surname" + }, + "prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prefix" + }, + "suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Suffix" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nickname" + }, + "is_primary": { + "type": "boolean", + "title": "Is Primary", + "default": false + } + }, + "type": "object", + "title": "NameCreate" + }, + "NameRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "tree_id": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + }, + "person_id": { + "type": "string", + "format": "uuid", + "title": "Person Id" + }, + "name_type": { + "type": "string", + "title": "Name Type" + }, + "given": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Given" + }, + "surname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Surname" + }, + "prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prefix" + }, + "suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Suffix" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nickname" + }, + "is_primary": { + "type": "boolean", + "title": "Is Primary" + }, + "sort_order": { + "type": "integer", + "title": "Sort Order" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "tree_id", + "person_id", + "name_type", + "given", + "surname", + "prefix", + "suffix", + "nickname", + "is_primary", + "sort_order", + "created_at" + ], + "title": "NameRead" + }, + "NameUpdate": { + "properties": { + "name_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name Type" + }, + "given": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Given" + }, + "surname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Surname" + }, + "prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prefix" + }, + "suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Suffix" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nickname" + }, + "is_primary": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Primary" + } + }, + "type": "object", + "title": "NameUpdate" + }, "ParentChildQualifier": { "type": "string", "enum": [ @@ -4063,6 +4633,18 @@ ], "title": "Email Verified At" }, + "self_person_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Self Person Id" + }, "created_at": { "type": "string", "format": "date-time", @@ -4079,6 +4661,24 @@ ], "title": "UserRead" }, + "UserSelfPersonUpdate": { + "properties": { + "self_person_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Self Person Id" + } + }, + "type": "object", + "title": "UserSelfPersonUpdate" + }, "ValidationError": { "properties": { "loc": {