From dffd05d3036222f6f2b5589c5d3b384e8020dd90 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:40:19 -0400 Subject: [PATCH] Add layered service/API for tenancy and people with the privacy seam Wires the data model through repository -> service -> API/v1. The privacy engine (app/services/privacy.py) is the single enforcement point: every read resolves visibility there (tree role, tree visibility, per-person override; living-person redaction is a marked Phase 2 TODO). All writes record an attributable AuditEntry. Endpoints: POST /users (open dev bootstrap until auth), GET /users/me, POST/GET /trees, GET /trees/{id}, and POST/GET /trees/{id}/persons. Authn is a temporary X-User-Id header shim; authz is membership-based (owner/editor/viewer). Domain errors map to 401/403/404/409. Verified on the deploy target: private tree -> 403 for non-members, missing actor -> 401, audit log populated. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/deps.py | 33 ++++++++ backend/app/api/v1/__init__.py | 10 +++ backend/app/api/v1/persons.py | 43 ++++++++++ backend/app/api/v1/trees.py | 33 ++++++++ backend/app/api/v1/users.py | 21 +++++ backend/app/core/db.py | 39 +++++++-- backend/app/main.py | 27 +++++- backend/app/repositories/__init__.py | 0 backend/app/repositories/base.py | 40 +++++++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/person.py | 27 ++++++ backend/app/schemas/tree.py | 23 +++++ backend/app/schemas/user.py | 22 +++++ backend/app/services/__init__.py | 0 backend/app/services/audit.py | 37 ++++++++ backend/app/services/exceptions.py | 18 ++++ backend/app/services/person_service.py | 113 +++++++++++++++++++++++++ backend/app/services/privacy.py | 62 ++++++++++++++ backend/app/services/tree_service.py | 61 +++++++++++++ backend/app/services/user_service.py | 44 ++++++++++ 20 files changed, 640 insertions(+), 13 deletions(-) create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/persons.py create mode 100644 backend/app/api/v1/trees.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/base.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/person.py create mode 100644 backend/app/schemas/tree.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/audit.py create mode 100644 backend/app/services/exceptions.py create mode 100644 backend/app/services/person_service.py create mode 100644 backend/app/services/privacy.py create mode 100644 backend/app/services/tree_service.py create mode 100644 backend/app/services/user_service.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..c9efb4f --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,33 @@ +"""Shared API dependencies.""" + +import uuid +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_session +from app.models.user import User +from app.services.user_service import get_user + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +async def get_current_user( + session: SessionDep, + x_user_id: Annotated[uuid.UUID | None, Header()] = None, +) -> User: + """TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id`` + header. Replaced by the AuthProvider (sessions/tokens) in the auth slice. + The assistant principal will also be minted here, scoped to its user.""" + if x_user_id is None: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)" + ) + user = await get_user(session, x_user_id) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..7e4a596 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,10 @@ +"""Versioned API surface. Mounts under /api/v1.""" + +from fastapi import APIRouter + +from app.api.v1 import persons, trees, users + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(users.router) +api_router.include_router(trees.router) +api_router.include_router(persons.router) diff --git a/backend/app/api/v1/persons.py b/backend/app/api/v1/persons.py new file mode 100644 index 0000000..8624918 --- /dev/null +++ b/backend/app/api/v1/persons.py @@ -0,0 +1,43 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.person import PersonCreate, PersonRead +from app.services import person_service, tree_service + +# Persons are nested under their tree (the tenant boundary). +router = APIRouter(prefix="/trees", tags=["persons"]) + + +@router.post( + "/{tree_id}/persons", + response_model=PersonRead, + status_code=status.HTTP_201_CREATED, +) +async def create_person( + tree_id: uuid.UUID, data: PersonCreate, session: SessionDep, current: CurrentUser +) -> PersonRead: + # get_tree enforces existence + view access; create_person enforces edit rights. + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + person = await person_service.create_person( + session, + actor=current, + tree=tree, + given=data.given, + surname=data.surname, + gender=data.gender, + is_living=data.is_living, + privacy_setting=data.privacy, + notes=data.notes, + ) + return PersonRead.model_validate(person) + + +@router.get("/{tree_id}/persons", response_model=list[PersonRead]) +async def list_persons( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[PersonRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) + return [PersonRead.model_validate(p) for p in persons] diff --git a/backend/app/api/v1/trees.py b/backend/app/api/v1/trees.py new file mode 100644 index 0000000..05ea73b --- /dev/null +++ b/backend/app/api/v1/trees.py @@ -0,0 +1,33 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.tree import TreeCreate, TreeRead +from app.services import tree_service + +router = APIRouter(prefix="/trees", tags=["trees"]) + + +@router.post("", response_model=TreeRead, status_code=status.HTTP_201_CREATED) +async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUser) -> TreeRead: + tree = await tree_service.create_tree( + session, + owner=current, + name=data.name, + description=data.description, + visibility=data.visibility, + ) + return TreeRead.model_validate(tree) + + +@router.get("", response_model=list[TreeRead]) +async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]: + trees = await tree_service.list_trees_for_user(session, user=current) + return [TreeRead.model_validate(t) for t in trees] + + +@router.get("/{tree_id}", response_model=TreeRead) +async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + return TreeRead.model_validate(tree) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..6b12887 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.user import UserCreate, UserRead +from app.services import user_service + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def create_user(data: UserCreate, session: SessionDep) -> UserRead: + # Open dev bootstrap until the auth slice; lets us create tree owners. + user = await user_service.create_user( + session, email=data.email, display_name=data.display_name + ) + return UserRead.model_validate(user) + + +@router.get("/me", response_model=UserRead) +async def read_me(current: CurrentUser) -> UserRead: + return UserRead.model_validate(current) diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 98f5658..a11feb0 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,22 +1,43 @@ -"""Async database engine. +"""Async database engine, session factory, and the FastAPI session dependency. -A single lazily-created async engine for the process. The repository layer -(coming with the data model) will build sessions on top of this; for now it -backs the readiness probe. +The repository layer builds on ``get_session``; ``get_engine`` also backs the +readiness probe. Everything is lazy so importing the app never opens a +connection (important for tests and for ``--help``-style invocations). """ -from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) from app.core.config import get_settings _engine: AsyncEngine | None = None +_sessionmaker: async_sessionmaker[AsyncSession] | None = None def get_engine() -> AsyncEngine: global _engine if _engine is None: - _engine = create_async_engine( - get_settings().database_url, - pool_pre_ping=True, - ) + _engine = create_async_engine(get_settings().database_url, pool_pre_ping=True) return _engine + + +def get_sessionmaker() -> async_sessionmaker[AsyncSession]: + global _sessionmaker + if _sessionmaker is None: + _sessionmaker = async_sessionmaker( + get_engine(), expire_on_commit=False, class_=AsyncSession + ) + return _sessionmaker + + +async def get_session() -> AsyncIterator[AsyncSession]: + """FastAPI dependency. One session per request; commits are explicit in the + service layer.""" + async with get_sessionmaker()() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py index 3af2627..2f3dfd6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,14 +1,31 @@ """FastAPI application entrypoint. -Thin by design: wire settings and routers, expose the OpenAPI contract. All -domain logic lives in the service layer (added with the data model). The -versioned API will mount under ``/api/v1``; health probes stay at the root. +Thin by design: wire settings, routers, and error handling, and expose the +OpenAPI contract. All domain logic lives in the service layer; the privacy +engine is the single enforcement point for reads. """ -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from app.api.health import router as health_router +from app.api.v1 import api_router from app.core.config import get_settings +from app.services.exceptions import Conflict, Forbidden, NotFound + + +def _register_error_handlers(app: FastAPI) -> None: + @app.exception_handler(NotFound) + async def _not_found(request: Request, exc: NotFound) -> JSONResponse: + return JSONResponse(status_code=404, content={"detail": str(exc) or "not found"}) + + @app.exception_handler(Forbidden) + async def _forbidden(request: Request, exc: Forbidden) -> JSONResponse: + return JSONResponse(status_code=403, content={"detail": str(exc) or "forbidden"}) + + @app.exception_handler(Conflict) + async def _conflict(request: Request, exc: Conflict) -> JSONResponse: + return JSONResponse(status_code=409, content={"detail": str(exc) or "conflict"}) def create_app() -> FastAPI: @@ -19,6 +36,8 @@ def create_app() -> FastAPI: description="Provenance API โ€” family and land provenance.", ) app.include_router(health_router) + app.include_router(api_router) + _register_error_handlers(app) return app diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repositories/base.py b/backend/app/repositories/base.py new file mode 100644 index 0000000..dac057a --- /dev/null +++ b/backend/app/repositories/base.py @@ -0,0 +1,40 @@ +"""Thin data-access layer over SQLAlchemy. No business rules live here โ€” the +service layer owns those (and the privacy engine). The repository only knows how +to fetch and stage rows, transparently excluding soft-deleted ones. +""" + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +class BaseRepository: + def __init__(self, session: AsyncSession, model: type) -> None: + self.session = session + self.model = model + + def _exclude_deleted(self, stmt): + if hasattr(self.model, "deleted_at"): + stmt = stmt.where(self.model.deleted_at.is_(None)) + return stmt + + async def get(self, id_: Any, *, include_deleted: bool = False): + stmt = select(self.model).where(self.model.id == id_) + if not include_deleted: + stmt = self._exclude_deleted(stmt) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list(self, *conditions, include_deleted: bool = False, order_by=None): + stmt = select(self.model) + for condition in conditions: + stmt = stmt.where(condition) + if not include_deleted: + stmt = self._exclude_deleted(stmt) + if order_by is not None: + stmt = stmt.order_by(order_by) + return list((await self.session.execute(stmt)).scalars().all()) + + def add(self, obj): + self.session.add(obj) + return obj diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py new file mode 100644 index 0000000..55eb3f3 --- /dev/null +++ b/backend/app/schemas/person.py @@ -0,0 +1,27 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.enums import PersonPrivacy + + +class PersonCreate(BaseModel): + given: str | None = None + surname: str | None = None + gender: str | None = None + is_living: bool | None = None + privacy: PersonPrivacy = PersonPrivacy.inherit + notes: str | None = None + + +class PersonRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tree_id: uuid.UUID + primary_name: str | None = None + gender: str | None + is_living: bool | None + privacy: PersonPrivacy + created_at: datetime diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py new file mode 100644 index 0000000..31007ee --- /dev/null +++ b/backend/app/schemas/tree.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.enums import TreeVisibility + + +class TreeCreate(BaseModel): + name: str + description: str | None = None + visibility: TreeVisibility = TreeVisibility.private + + +class TreeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + description: str | None + visibility: TreeVisibility + owner_id: uuid.UUID + created_at: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..0535f73 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +# Note: email is a plain str for now (no email-validator dependency yet); the +# auth slice can tighten this to EmailStr. + + +class UserCreate(BaseModel): + email: str + display_name: str | None = None + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + display_name: str | None + email_verified_at: datetime | None + created_at: datetime diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..0c87831 --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,37 @@ +"""Audit logging. Every mutation records an append-only AuditEntry attributing +the change to a User (or the assistant principal acting for a User). Staged on +the session; the caller commits as part of its unit of work. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.audit import AuditEntry +from app.models.enums import AuditActorType + + +def record_audit( + session: AsyncSession, + *, + action: str, + entity_type: str, + entity_id: uuid.UUID | None = None, + tree_id: uuid.UUID | None = None, + actor_user_id: uuid.UUID | None = None, + actor_type: AuditActorType = AuditActorType.user, + before: dict | None = None, + after: dict | None = None, +) -> AuditEntry: + entry = AuditEntry( + action=action, + entity_type=entity_type, + entity_id=entity_id, + tree_id=tree_id, + actor_user_id=actor_user_id, + actor_type=actor_type, + before=before, + after=after, + ) + session.add(entry) + return entry diff --git a/backend/app/services/exceptions.py b/backend/app/services/exceptions.py new file mode 100644 index 0000000..3268abd --- /dev/null +++ b/backend/app/services/exceptions.py @@ -0,0 +1,18 @@ +"""Domain errors. The API layer maps these to HTTP status codes so services +stay transport-agnostic.""" + + +class DomainError(Exception): + """Base for domain-level errors.""" + + +class NotFound(DomainError): + """Requested entity does not exist (or is soft-deleted / not visible).""" + + +class Forbidden(DomainError): + """Caller lacks the required role for this action.""" + + +class Conflict(DomainError): + """Operation conflicts with current state (e.g. duplicate email).""" diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py new file mode 100644 index 0000000..320deb1 --- /dev/null +++ b/backend/app/services/person_service.py @@ -0,0 +1,113 @@ +"""Person service. Writes require editor rights on the tree; reads run every +person through the privacy engine. Each returned Person gets a transient +``primary_name`` for display (not persisted). +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import PersonPrivacy +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 +from app.services.privacy import Visibility + + +def _format_name(name: Name) -> str | None: + parts = [name.given, name.surname] + joined = " ".join(p for p in parts if p) + return joined or name.display_name + + +async def _attach_primary_name(session: AsyncSession, person: Person) -> None: + stmt = ( + select(Name) + .where(Name.person_id == person.id, Name.deleted_at.is_(None)) + .order_by(Name.is_primary.desc(), Name.sort_order) + ) + name = (await session.execute(stmt)).scalars().first() + # Transient display attribute consumed by the PersonRead schema. + person.primary_name = _format_name(name) if name is not None else None + + +async def create_person( + session: AsyncSession, + *, + actor: User, + tree: Tree, + given: str | None = None, + surname: str | None = None, + gender: str | None = None, + is_living: bool | None = None, + privacy_setting: PersonPrivacy = PersonPrivacy.inherit, + notes: str | None = None, +) -> Person: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + + person = Person( + tree_id=tree.id, + gender=gender, + is_living=is_living, + privacy=privacy_setting, + notes=notes, + ) + session.add(person) + await session.flush() # assign person.id + + if given or surname: + session.add( + Name( + tree_id=tree.id, + person_id=person.id, + name_type="birth", + given=given, + surname=surname, + is_primary=True, + ) + ) + record_audit( + session, + action="create", + entity_type="Person", + entity_id=person.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"given": given, "surname": surname}, + ) + await session.commit() + await session.refresh(person) + await _attach_primary_name(session, person) + return person + + +async def list_persons( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree +) -> list[Person]: + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + + stmt = ( + select(Person) + .where(Person.tree_id == tree.id, Person.deleted_at.is_(None)) + .order_by(Person.created_at) + ) + persons = list((await session.execute(stmt)).scalars().all()) + + visible: list[Person] = [] + for person in persons: + if ( + await privacy.person_visibility( + session, user_id=viewer_id, tree=tree, person=person + ) + == Visibility.hidden + ): + continue + await _attach_primary_name(session, person) + visible.append(person) + return visible diff --git a/backend/app/services/privacy.py b/backend/app/services/privacy.py new file mode 100644 index 0000000..e077af0 --- /dev/null +++ b/backend/app/services/privacy.py @@ -0,0 +1,62 @@ +"""The privacy engine โ€” the single enforcement point for visibility. + +INVARIANT (CLAUDE.md #2): every read path resolves visibility here. Do not add a +query path that returns rows to a caller without first passing through this +module. Effective visibility is a function of the viewer's role on the tree, the +tree's visibility, the per-person override, and (Phase 2) living-person status. +""" + +import enum +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility +from app.models.person import Person +from app.models.tree import Tree, TreeMembership + + +class Visibility(enum.StrEnum): + full = "full" + redacted = "redacted" + hidden = "hidden" + + +async def get_membership_role( + session: AsyncSession, user_id: uuid.UUID | None, tree_id: uuid.UUID +) -> MembershipRole | None: + if user_id is None: + return None + stmt = select(TreeMembership.role).where( + TreeMembership.tree_id == tree_id, + TreeMembership.user_id == user_id, + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool: + if tree.deleted_at is not None: + return False + if await get_membership_role(session, user_id, tree.id) is not None: + return True + return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted) + + +async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool: + role = await get_membership_role(session, user_id, tree.id) + return role in (MembershipRole.owner, MembershipRole.editor) + + +async def person_visibility( + session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person +) -> Visibility: + if not await can_view_tree(session, user_id=user_id, tree=tree): + return Visibility.hidden + if await get_membership_role(session, user_id, tree.id) is not None: + return Visibility.full + # Non-member viewing a public/unlisted tree: + if person.privacy == PersonPrivacy.private: + return Visibility.hidden + # TODO(Phase 2): redact living people for non-members (ARCHITECTURE ยง6). + return Visibility.full diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py new file mode 100644 index 0000000..ffc4c59 --- /dev/null +++ b/backend/app/services/tree_service.py @@ -0,0 +1,61 @@ +"""Tree service. Creating a tree also creates the owner's TreeMembership (the +authorization basis) and an audit entry. Reads go through the privacy engine. +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import MembershipRole, TreeVisibility +from app.models.tree import Tree, TreeMembership +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 Forbidden, NotFound + + +async def create_tree( + session: AsyncSession, + *, + owner: User, + name: str, + description: str | None = None, + visibility: TreeVisibility = TreeVisibility.private, +) -> Tree: + tree = Tree(owner_id=owner.id, name=name, description=description, visibility=visibility) + session.add(tree) + await session.flush() # assign tree.id + session.add(TreeMembership(tree_id=tree.id, user_id=owner.id, role=MembershipRole.owner)) + record_audit( + session, + action="create", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=owner.id, + after={"name": name, "visibility": visibility.value}, + ) + await session.commit() + await session.refresh(tree) + return tree + + +async def list_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]: + stmt = ( + select(Tree) + .join(TreeMembership, TreeMembership.tree_id == Tree.id) + .where(TreeMembership.user_id == user.id, Tree.deleted_at.is_(None)) + .order_by(Tree.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid.UUID) -> Tree: + tree = await BaseRepository(session, Tree).get(tree_id) + if tree is None: + raise NotFound("tree not found") + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + return tree diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..61c4498 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,44 @@ +"""User service. Account creation here is a temporary, open dev bootstrap so we +can create tree owners before the auth slice exists; the auth slice replaces it +with the AuthProvider (password/OIDC/social) and proper verification. +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.repositories.base import BaseRepository +from app.services.audit import record_audit +from app.services.exceptions import Conflict + + +async def create_user( + session: AsyncSession, *, email: str, display_name: str | None = None +) -> User: + email = email.strip().lower() + existing = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if existing is not None: + raise Conflict("email already registered") + + user = User(email=email, display_name=display_name) + session.add(user) + await session.flush() # assign user.id + record_audit( + session, + action="create", + entity_type="User", + entity_id=user.id, + actor_user_id=user.id, + after={"email": email}, + ) + await session.commit() + await session.refresh(user) + return user + + +async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None: + return await BaseRepository(session, User).get(user_id)