39 Commits

Author SHA1 Message Date
justin 22bc536978 Rebuild People as a family view (pedigree + family group); add recovery UI
The People page is no longer a flat list: it's a focus-person family view with a pedigree of ancestors (parents + grandparents), a spouse/partner panel, and a children panel — with inline 'add parent/child/spouse' (creates the person + the relationship), click-to-refocus, birth–death years, and a searchable people index. Modeled on how real genealogy tools center on a person and let you walk the graph.

Adds delete/restore UI: a Delete on the person page, per-tree delete + a 'Recently deleted' restore section on the trees list, and a Recovery page (sidebar) for deleted people.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin f2205b93f4 Add soft-delete + recovery and tree-wide graph endpoints
Tree and person soft-delete + restore (owner-only for trees, editor for people) with recovery listings (?deleted=true); the worker already purges past the 30-day window. Adds tree-wide GET /relationships and /events so the family/pedigree view loads the whole graph in a few calls. 27 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin b0c7c8570b Merge pull request 'App-shell UI overhaul + media stream endpoint' (#9) from ui-shell into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m20s
2026-06-06 21:56:26 -04:00
justin fe9a95c60d Rebuild the UI as an app shell: left sidebar, media gallery, structured events
Replaces the centered single-column of full-width cards with a proper application layout: a persistent left sidebar (Trees, and per-tree People/Sources/Media, with the tree name and sign-out) and a constrained content column. Marketing landing and auth pages are split out (own header/footer; centered auth with the logo).

Adds a Media gallery (upload + image thumbnails / file tiles, served via the backend content endpoint). Events are no longer free-text: a curated event-type list (+ custom) and a structured date (qualifier + day/month/year) that composes a proper genealogical date. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:56:05 -04:00
justin bd8ee9b647 Stream media through the backend (browser-reachable, privacy-checked)
Presigned URLs point at the internal minio:9000 host a browser can't reach. Add ObjectStore.get_object and a GET /media/{id}/content endpoint that resolves visibility and streams the bytes; MediaRead.url now points there. Keeps the object store private and downloads behind the privacy engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:56:04 -04:00
justin 660130f007 Merge pull request 'Phase 1: media (object storage) + background worker' (#8) from phase1-media into main
build-backend / build (push) Successful in 30s
2026-06-06 21:46:35 -04:00
justin 34d30e3134 Add media (object storage) and the background worker (Phase 1)
Media model + migration; an ObjectStore interface with an S3/MinIO (boto3) implementation behind the service layer. Upload (multipart) stores bytes in object storage + a metadata row (checksum, size, content-type, optional attach to person/event/source); list returns presigned URLs; delete is soft. Editor-gated, privacy-filtered, audited. 24 tests pass (object store faked).

Introduces the worker container (same image, 'python -m app.worker'): its first job is the scheduled 30-day soft-delete purge across tables + media object cleanup. Compose gains worker + S3 env on backend/worker; dev override builds the worker too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:46:09 -04:00
justin 049545fcc8 Merge pull request 'Frontend redesign: real type, hero, depth' (#7) from design-overhaul into main
build-frontend / build (push) Successful in 1m18s
2026-06-06 21:34:48 -04:00
justin 3a14fcc4ca Redesign the frontend: real type, hero landing, depth
Lifts the UI from wireframe to a finished heritage look: Fraunces (display serif) + Inter (sans) via next/font; a proper hero landing with a feature triad and the Origin mark; a warm bronze-tinted background gradient for depth; a sticky branded header and refined footer. Polished button (sizes + bronze focus ring + shadow), card (rounded-xl, soft layered shadow), and input (bronze focus) primitives that carry across every page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:34:47 -04:00
justin fc4cb0273e Merge pull request 'Phase 1: sources-first spine (sources + citations)' (#6) from phase1-sources into main
build-backend / build (push) Successful in 24s
build-frontend / build (push) Successful in 1m18s
2026-06-06 13:17:34 -04:00
justin 83f83ab641 Add source manager and inline citing with 'sourced' badges
New /trees/[id]/sources page (list + create sources). Person-detail page now loads tree sources + citations and shows a '✓ N sourced' badge with an inline cite picker (source + page) on each event and on the person. Tree view links to Sources. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin 064bb6ea65 Add sources and citations API (Phase 1: sources-first spine)
Source CRUD (reusable, tree-scoped) and Citation create/list/soft-delete linking one source to exactly one fact (person/event/name/relationship). Editor-gated writes, privacy-filtered reads, audit throughout; tenant + existence validation on source and target. list_citations returns all tree citations so the UI can render 'sourced' indicators in one round-trip. 22 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin fbb9d0195c Merge pull request 'Phase 1: events + relationships + person detail' (#5) from phase1-graph into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m16s
2026-06-06 12:11:11 -04:00
justin 1f25eb2f21 Add person-detail page with events timeline and relationships
New /trees/[id]/persons/[personId] view: life-events timeline with add/remove, and relationships grouped into parents/children/partners/siblings with an add form (kind + person picker + qualifier). People in the tree list now link here. Regenerated the OpenAPI client for the new endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 12:10:56 -04:00
justin d6e2df4a61 Add events and relationships API (Phase 1: flesh out the graph)
Events (create/list-per-person/soft-delete) and relationships (create/list-per-person/soft-delete) through the layered stack: editor-gated writes, privacy-engine reads, audit on every change. Events carry exactly one subject (person XOR partnership); relationships are typed qualified edges (parent_child gets a biological/adoptive/step/foster/donor/guardian qualifier). Adds a single-person GET. 18 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 12:10:56 -04:00
justin a799d101b5 Merge pull request 'Use host global Watchtower (drop bundled one)' (#4) from watchtower-use-host into main 2026-06-06 11:58:50 -04:00
justin 0b9d72c878 Drop bundled Watchtower; rely on the host's global Watchtower
ripper already runs a single global nickfedor/watchtower (label-enabled) that watches every stack; the bundled containrrr/watchtower was redundant and crash-looped (its Docker API client is too old for Docker 29). Keep the watchtower.enable labels on backend/frontend so the host instance auto-deploys them; remove the per-stack service and profile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:58:49 -04:00
justin 2d0635e710 Merge pull request 'Add Watchtower auto-deploy (2-min poll)' (#3) from watchtower-autodeploy into main 2026-06-06 11:55:51 -04:00
justin 768d1b23d4 Add Watchtower auto-deploy for app images (2-minute poll)
Watchtower (profile-gated) watches only the label-enabled backend/frontend containers and recreates them when a new :test-main digest lands in the registry, polling every 120s. Scoped by label so it never touches Postgres/MinIO/Caddy/cloudflared. Reads registry creds from the host docker config. Lab host runs COMPOSE_PROFILES=tunnel,watchtower.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:55:38 -04:00
justin 11f0f79866 Merge pull request 'Frontend rebrand: ink + bronze + paper' (#2) from frontend-rebrand into main
build-frontend / build (push) Successful in 1m16s
2026-06-06 11:51:13 -04:00
justin b8f5c35045 Apply brand identity to the frontend (ink + bronze + paper)
Replaces the default black/gray with the docs/brand palette: warm ink text on paper surfaces, bronze accent, serif headings and the Origin-mark wordmark in the header, favicon, and the 'where it came from matters' tagline. Light/dark adapt via CSS vars (ink/paper flip); bronze and paper are constant. Tailwind v4 @theme exposes bronze/paper/ink tokens and the serif stack. Buttons/inputs/cards restyled to match; brand SVGs vendored into public/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:49:58 -04:00
justin 9e6cf6e5b7 Merge pull request 'Phase 0 — Foundation: backend, data model, local auth, frontend, deploy + CI' (#1) from phase-0-foundation into main
build-backend / build (push) Failing after 26s
build-frontend / build (push) Failing after 1m16s
2026-06-06 11:32:31 -04:00
justin 828445a6b3 Add Cloudflare Tunnel connector (profile-gated) to the deploy stack
A cloudflared service (opt-in via the 'tunnel' compose profile, token from CLOUDFLARE_TUNNEL_TOKEN) connects the lab to Cloudflare. One public hostname -> http://caddy:80 is sufficient because Caddy does the internal path routing. Mirrors the drawbar tunnel setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:32:15 -04:00
justin 4921ce0776 Mirror drawbar CI/CD: push to LAN registry, pull via public FQDN
Split the registry endpoints like the drawbar containers. Per-component Gitea Actions workflows (build-backend, build-frontend; runs-on docker, path-filtered) push images to the LAN endpoint 192.168.0.2:1234 over plain HTTP (buildx insecure/http) to bypass Cloudflare's request-body limit, then link each package to the repo via the Gitea API. Auth via the REGISTRY_TOKEN Actions secret (the same token drawbar uses). Tag scheme: test-main / test-sha-<long> / version / latest (v* tags).

The deploy compose now PULLS git.jpaul.io/justin/provenance-{backend,frontend}:${IMAGE_TAG:-test-main} (no host build); docker-compose.dev.yml is a local-build override for dev / pre-CI. Replaces the previous single build.yml. Docs + memory updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:19:26 -04:00
justin 6c3a9a89aa Mark Phase 0 complete in CLAUDE.md and ARCHITECTURE
Records the landed frontend scaffold and that Phase 0 (backend, data model, local auth, deploy stack, CI, frontend) is complete and running live; Phase 1 (core tree features) is the new current target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:03:07 -04:00
justin fccc81a6cc Wire the frontend into the deploy stack and CI
Compose gains a frontend service; Caddy now routes / to frontend:3000 (keeping /api/* and /health* on the backend). CI builds and pushes a frontend image alongside the backend. Verified end-to-end on the deploy target: / serves the app, /api and /health still resolve through Caddy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:03:07 -04:00
justin a5a79f01a7 Scaffold Next.js frontend with generated OpenAPI client and core views
Next.js (App Router) + React 19 + TypeScript + Tailwind v4, with shadcn-style UI primitives (Button, Input, Card, Label via cva/tailwind-merge). A typed API client is generated from the backend OpenAPI spec with openapi-typescript + openapi-fetch (npm run gen:api); the committed openapi.json/schema.d.ts are the snapshot.

Views: landing, login, register, tree list + create, and tree detail with person list + create. Auth rides the same-origin HttpOnly session cookie the backend sets (Caddy proxies /api/*), so no token handling in JS. Built as a standalone container. Mobile-first; next build is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:03:07 -04:00
justin e2edd4b2f1 Document landed local auth in CLAUDE.md and ARCHITECTURE
Records the auth model (Argon2, opaque sessions, Bearer/cookie, email verify/reset behind AuthProvider/Mailer), supersedes the interim X-User-Id note, and adds integrations/ to the backend layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:51:51 -04:00
justin 9f8dd960f4 Test auth flows and switch core tests to session auth
New auth suite covers registration, login (incl. wrong-password), email verification, password reset (old sessions + old password rejected), logout revocation, and no-enumeration on reset. Core tenancy tests now authenticate via real sessions. A capturing mailer makes email flows assertable. 13 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:51:51 -04:00
justin 00bfe8bfca Add local auth: AuthProvider, mailer, sessions, /api/v1/auth
Pluggable AuthProvider interface with a local (email+password) implementation, and a Mailer interface (ConsoleMailer for dev, SMTPMailer for operators). The auth service owns registration, login, opaque session issuance, email verification, and password reset (which revokes prior sessions). Endpoints under /api/v1/auth; sessions are returned as a Bearer token and set as an HttpOnly cookie.

Replaces the temporary X-User-Id shim: get_current_user now resolves a real session (Bearer or cookie). The open user-bootstrap endpoint is gone (registration replaces it). App logging is configured so the ConsoleMailer's verification/reset links are visible to self-hosters. Verified end-to-end on the deploy target, including the email-verification flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:51:51 -04:00
justin 5123c85397 Add auth foundation: sessions/tokens schema, Argon2 hashing, config
Two tables (sessions, user_tokens) + migration; only token *hashes* are stored, so a DB leak yields no usable credential. Argon2id password hashing and token primitives in app/core/security. Config and .env.example gain session/cookie/token TTLs, app base URL, and SMTP settings (twelve-factor). Migration verified reversible (drops the token_purpose enum) and matches the models.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:51:51 -04:00
justin e5a8713293 Document core-model decisions in CLAUDE.md and ARCHITECTURE
Records the landed data model and backend layout, the Phase 0 tree-scoping of Place (vs. the eventual shared gazetteer), and the temporary X-User-Id auth shim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:40:19 -04:00
justin 64388b75bf Add core API integration tests
End-to-end coverage of the tenancy/people flow and the privacy seam (private-tree isolation, public-tree view-but-not-edit, duplicate-email conflict, auth-required). DB-backed tests run against TEST_DATABASE_URL and skip cleanly when it is unset, so the no-DB suite still runs anywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:40:19 -04:00
justin dffd05d303 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) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:40:19 -04:00
justin 297cb797d6 Add core data model (12 tables) and initial Alembic migration
All core entities from ARCHITECTURE §5: tenancy (User, Tree, TreeMembership), people (Person, Name, Relationship), facts (Event, Place, PlaceName), provenance (Source, Citation), and the append-only AuditEntry. Cross-cutting mixins give every row a UUID key, timestamps, soft delete, and (where tree-owned) a tree_id for uniform tenant isolation.

Modeling choices: parentage as qualified edges (biological/adoptive/step/foster/donor/guardian) so non-traditional families are first-class; events keep both a verbatim date string and a normalized start/end range; closed sets are PG enums while GEDCOM-extensible vocabularies (event/name/source type) stay strings; CHECK constraints enforce single-subject events and single-target citations. Place is tree-scoped in Phase 0 (see ARCHITECTURE note). The migration is verified reversible (upgrade/downgrade drops tables and enum types) and matches the models (alembic check clean); applied on the deploy target. Dockerfile now ships migrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:40:00 -04:00
justin 03124027fe Record Phase 0 repo layout in CLAUDE.md and ARCHITECTURE
Documents the scaffolded tree (/backend, /deploy, /.gitea, pending /frontend), the deploy-first sequencing, and the toolchain choices (uv for backend deps, Alembic for migrations), as CLAUDE.md's layout section requires when code lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:17:12 -04:00
justin 9e4252ba8f Add Gitea Actions CI to build the backend image
Builds and pushes the backend container image to the Gitea registry on git.jpaul.io on push to main and version tags, so servers pull to deploy (no build on the host). Registry credentials come from repo secrets (REGISTRY_USERNAME/REGISTRY_PASSWORD); runner label may need adjusting to the configured Gitea runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:17:12 -04:00
justin 0b5c3b260a Add self-host compose stack (Postgres, MinIO, backend, Caddy)
One env-driven compose stack stands up the whole system per ARCHITECTURE §2/§12. Postgres uses the pgvector image (pgvector + pg_trgm in contrib); MinIO is the S3-compatible store; Caddy reverse-proxies /api/* and /health* to the backend with an env-driven site address (':80' local, a domain for auto-HTTPS, or plain HTTP behind a Cloudflare Tunnel). Healthchecks and depends_on gate startup order.

.env.example documents twelve-factor config (DB, S3, SMTP, Caddy, model keys) with placeholders; no secrets in the repo. Verified end-to-end on the deploy target: all services healthy, /health/ready green against real Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:17:12 -04:00
justin 03aa9a3ca7 Scaffold FastAPI backend skeleton with health probes
Phase 0 foundation. uv-managed FastAPI app (package=false, runs from source via uv run). Layered seams in place: app/api for routers, app/core for config (pydantic-settings, fully env-driven) and the async SQLAlchemy engine; service/repository/domain layers land with the data model.

Exposes /health (liveness) and /health/ready (Postgres reachability via SELECT 1, 503 on failure) so the deploy wiring is verifiable before any data model exists. Includes a liveness test and the resolved uv.lock. Ignore pytest/ruff/mypy caches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:16:58 -04:00
129 changed files with 15371 additions and 6 deletions
+105
View File
@@ -0,0 +1,105 @@
name: build-backend
# Builds + pushes the backend image to justin/provenance-backend's package area
# on Gitea on every merge to main. Servers pull from git.jpaul.io.
#
# Push goes to the LAN registry endpoint 192.168.0.2:1234 (plain HTTP) to bypass
# Cloudflare's request-body limit; pulls use the public git.jpaul.io FQDN. Same
# Gitea registry either way. Mirrors the drawbar setup.
#
# Tag scheme: test-main | test-sha-<long> | <semver from pyproject> | latest (v* tags)
on:
workflow_dispatch:
push:
branches: [main]
tags: ['v*']
paths:
- 'backend/**'
- '.gitea/workflows/build-backend.yml'
concurrency:
group: build-backend-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from pyproject.toml
id: ver
run: |
v=$(grep -oP '^version = "\K[^"]+' backend/pyproject.toml | head -1)
if [ -z "$v" ]; then echo "could not parse version from backend/pyproject.toml"; exit 1; fi
echo "semver=$v" >> "$GITHUB_OUTPUT"
echo "backend semver: $v"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# LAN registry serves plain HTTP on :1234 (git.jpaul.io is the only TLS
# endpoint, via Cloudflare). Treat the LAN endpoint as insecure so
# buildkit doesn't try to upgrade the push to HTTPS.
config-inline: |
[registry."192.168.0.2:1234"]
http = true
insecure = true
- name: Configure registry credentials for buildx
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ github.actor }}
run: |
mkdir -p ~/.docker
AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 -w0)
cat > ~/.docker/config.json <<EOF
{
"auths": {
"192.168.0.2:1234": {
"auth": "$AUTH"
}
}
}
EOF
- name: Compute tags
id: meta
uses: docker/metadata-action@v5
with:
images: 192.168.0.2:1234/justin/provenance-backend
tags: |
type=ref,event=branch,prefix=test-
type=sha,prefix=test-sha-,format=long
type=raw,value=${{ steps.ver.outputs.semver }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
labels: |
org.opencontainers.image.source=https://git.jpaul.io/justin/provenance
org.opencontainers.image.version=${{ steps.ver.outputs.semver }}
- name: Build and push (amd64)
uses: docker/build-push-action@v6
with:
context: ./backend
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Link package to the provenance repo
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
code=$(curl -s -o /tmp/link.out -w "%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://git.jpaul.io/api/v1/packages/justin/container/provenance-backend/-/link/provenance")
echo "link -> provenance: HTTP $code"
case "$code" in
201) echo "OK — newly linked" ;;
400|409) echo "OK — already linked" ;;
*) cat /tmp/link.out; exit 1 ;;
esac
+102
View File
@@ -0,0 +1,102 @@
name: build-frontend
# Builds + pushes the Next.js image to justin/provenance-frontend's package area
# on Gitea on every merge to main. Servers pull from git.jpaul.io.
#
# Push -> LAN registry 192.168.0.2:1234 (plain HTTP); pull -> git.jpaul.io.
# Mirrors the drawbar setup; see build-backend.yml for the rationale.
#
# Tag scheme: test-main | test-sha-<long> | <version from package.json> | latest (v* tags)
on:
workflow_dispatch:
push:
branches: [main]
tags: ['v*']
paths:
- 'frontend/**'
- '.gitea/workflows/build-frontend.yml'
concurrency:
group: build-frontend-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from package.json
id: ver
run: |
v=$(grep -oP '"version"\s*:\s*"\K[^"]+' frontend/package.json | head -1)
if [ -z "$v" ]; then echo "could not parse version from frontend/package.json"; exit 1; fi
echo "semver=$v" >> "$GITHUB_OUTPUT"
echo "frontend version: $v"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# See build-backend.yml for why these flags are needed.
config-inline: |
[registry."192.168.0.2:1234"]
http = true
insecure = true
- name: Configure registry credentials for buildx
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ github.actor }}
run: |
mkdir -p ~/.docker
AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 -w0)
cat > ~/.docker/config.json <<EOF
{
"auths": {
"192.168.0.2:1234": {
"auth": "$AUTH"
}
}
}
EOF
- name: Compute tags
id: meta
uses: docker/metadata-action@v5
with:
images: 192.168.0.2:1234/justin/provenance-frontend
tags: |
type=ref,event=branch,prefix=test-
type=sha,prefix=test-sha-,format=long
type=raw,value=${{ steps.ver.outputs.semver }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
labels: |
org.opencontainers.image.source=https://git.jpaul.io/justin/provenance
org.opencontainers.image.version=${{ steps.ver.outputs.semver }}
- name: Build and push (amd64)
uses: docker/build-push-action@v6
with:
context: ./frontend
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Link package to the provenance repo
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
code=$(curl -s -o /tmp/link.out -w "%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://git.jpaul.io/api/v1/packages/justin/container/provenance-frontend/-/link/provenance")
echo "link -> provenance: HTTP $code"
case "$code" in
201) echo "OK — newly linked" ;;
400|409) echo "OK — already linked" ;;
*) cat /tmp/link.out; exit 1 ;;
esac
+5
View File
@@ -25,6 +25,11 @@ target/
dist/ dist/
build/ build/
# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
+7 -3
View File
@@ -29,7 +29,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
- **Object storage:** S3-compatible (MinIO for self-host). - **Object storage:** S3-compatible (MinIO for self-host).
- **Edge:** Caddy reverse proxy; optional Cloudflare Tunnel (preferred ingress, never required). - **Edge:** Caddy reverse proxy; optional Cloudflare Tunnel (preferred ingress, never required).
- **Email:** operator-configured SMTP. - **Email:** operator-configured SMTP.
- **CI/CD:** Gitea Actions on `git.jpaul.io` build container images to the Gitea registry; servers pull to deploy. - **CI/CD:** Gitea Actions build per-component images. **Push** to the LAN registry `192.168.0.2:1234` (plain HTTP, bypasses Cloudflare's body limit); **pull** via the public `git.jpaul.io` FQDN. Servers pull to deploy — no host build. Mirrors the drawbar setup; see [[gitea-lan-push-fqdn-pull]].
Pick libraries consistent with this stack. If you introduce a significant dependency or a new service, note it in ARCHITECTURE.md in the same change. Pick libraries consistent with this stack. If you introduce a significant dependency or a new service, note it in ARCHITECTURE.md in the same change.
@@ -38,13 +38,17 @@ Pick libraries consistent with this stack. If you introduce a significant depend
``` ```
/ # docs and project meta (this file, README, LICENSE, COC, CONTRIBUTING) / # docs and project meta (this file, README, LICENSE, COC, CONTRIBUTING)
/docs # PRD.md, ARCHITECTURE.md /docs # PRD.md, ARCHITECTURE.md
/backend # FastAPI service (uv-managed). app/{api/v1, services (+ privacy engine), repositories, models, schemas, integrations (auth/mailer), core}; migrations/ = Alembic
/deploy # docker-compose.yml, Caddyfile, .env.example — the self-host stack
/.gitea/workflows # Gitea Actions CI (build images → Gitea registry)
/frontend # Next.js (App Router, TS, Tailwind, shadcn-style UI). app/ pages, lib/api generated OpenAPI client, components/ui
``` ```
Code does not exist yet — Phase 0 has not landed. When you scaffold it, propose a layout (e.g. `/backend`, `/frontend`, `/deploy` for compose/Caddy) and record it here and in ARCHITECTURE.md. Keep this section current as the tree grows. Phase 0 is landing **deploy-first**: the compose stack (Postgres + MinIO + Caddy + a minimal FastAPI backend exposing `/health` and `/health/ready`) and CI come before the real data model and the frontend. Backend dependencies are managed with **uv**; migrations use **Alembic**. The core data model (ARCHITECTURE §5), **local auth** (Argon2 passwords, backend-issued sessions, email verify/reset behind the `AuthProvider` interface; API auth via Bearer header or HttpOnly cookie), and the **Next.js frontend scaffold** (Tailwind + shadcn-style UI, generated OpenAPI client, auth + tree/person views) have all landed — **Phase 0 is complete and running on the live deployment.** Phase 1 (core tree features — media, soft-delete recovery, richer CRUD) is next; OIDC/social auth is Phase 5. Keep this section current as the tree grows.
## Where to start ## Where to start
The roadmap is phased in PRD §8. Build in dependency order. **Phase 0 — Foundation** is the current target: The roadmap is phased in PRD §8. Build in dependency order. **Phase 0 — Foundation is complete** and running on the live deployment; **Phase 1 (core tree features) is the current target.** For reference, Phase 0 covered:
1. Backend skeleton (FastAPI, async, layered) + Postgres + migrations 1. Backend skeleton (FastAPI, async, layered) + Postgres + migrations
2. Core data model from ARCHITECTURE §5 — start with User, Tree, TreeMembership, Person, Name, Relationship, Event, Place, Source, Citation, AuditEntry, soft-delete support 2. Core data model from ARCHITECTURE §5 — start with User, Tree, TreeMembership, Person, Name, Relationship, Event, Place, Source, Citation, AuditEntry, soft-delete support
+10
View File
@@ -0,0 +1,10 @@
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.env
.env.*
!.env.example
*.md
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1
# uv-managed Python image keeps the toolchain reproducible. Pinned to 3.13 for
# broad wheel availability (asyncpg etc.); bump when 3.14 wheels are ubiquitous.
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
# Dependencies first for layer caching. uv.lock is optional on first build;
# `uv sync` resolves and writes it if absent.
COPY pyproject.toml uv.lock* ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --no-dev
# Application source + migrations (project is package=false, no install step).
COPY app ./app
COPY alembic.ini ./alembic.ini
COPY migrations ./migrations
EXPOSE 8000
CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+41
View File
@@ -0,0 +1,41 @@
# Alembic config. The database URL is injected from DATABASE_URL in
# migrations/env.py (twelve-factor) — intentionally not set here.
[alembic]
script_location = migrations
prepend_sys_path = .
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
View File
View File
+57
View File
@@ -0,0 +1,57 @@
"""Shared API dependencies: DB session, the authenticated user, and the mailer."""
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.db import get_session
from app.integrations.mailer.base import Mailer
from app.integrations.mailer.console import ConsoleMailer
from app.integrations.mailer.smtp import SMTPMailer
from app.integrations.objectstore.base import ObjectStore
from app.integrations.objectstore.s3 import S3ObjectStore
from app.models.user import User
from app.services import auth_service
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def extract_session_token(request: Request) -> str | None:
"""Bearer header (API clients) takes precedence over the session cookie
(browser)."""
authorization = request.headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip()
return request.cookies.get(get_settings().cookie_name)
async def get_current_user(request: Request, session: SessionDep) -> User:
raw_token = extract_session_token(request)
if raw_token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required")
user = await auth_service.resolve_session_user(session, raw_token=raw_token)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or expired session")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_mailer() -> Mailer:
settings = get_settings()
if settings.mailer == "smtp" and settings.smtp_host:
return SMTPMailer(settings)
return ConsoleMailer()
MailerDep = Annotated[Mailer, Depends(get_mailer)]
def get_objectstore() -> ObjectStore:
return S3ObjectStore(get_settings())
ObjectStoreDep = Annotated[ObjectStore, Depends(get_objectstore)]
+41
View File
@@ -0,0 +1,41 @@
"""Liveness and readiness endpoints.
- ``/health`` — liveness: the process is up. No dependencies touched.
- ``/health/ready`` — readiness: dependencies (Postgres) are reachable.
Orchestrators and Caddy probe these; they are intentionally outside the
versioned ``/api`` surface.
"""
from fastapi import APIRouter, Response, status
from sqlalchemy import text
from app.core.config import get_settings
from app.core.db import get_engine
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> dict:
settings = get_settings()
return {
"status": "ok",
"service": settings.app_name,
"version": settings.version,
"env": settings.app_env,
}
@router.get("/health/ready")
async def ready(response: Response) -> dict:
checks: dict[str, str] = {}
try:
async with get_engine().connect() as conn:
await conn.execute(text("SELECT 1"))
checks["database"] = "ok"
return {"status": "ready", "checks": checks}
except Exception as exc: # noqa: BLE001 — surface any failure as "not ready"
checks["database"] = "error"
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not ready", "checks": checks, "detail": str(exc)}
+26
View File
@@ -0,0 +1,26 @@
"""Versioned API surface. Mounts under /api/v1."""
from fastapi import APIRouter
from app.api.v1 import (
auth,
citations,
events,
media,
persons,
relationships,
sources,
trees,
users,
)
api_router = APIRouter(prefix="/api/v1")
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(events.router)
api_router.include_router(relationships.router)
api_router.include_router(sources.router)
api_router.include_router(citations.router)
api_router.include_router(media.router)
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.api.deps import MailerDep, SessionDep, extract_session_token
from app.core.config import get_settings
from app.schemas.auth import (
LoginRequest,
PasswordResetConfirm,
PasswordResetRequest,
RegisterRequest,
SessionRead,
TokenRequest,
)
from app.schemas.user import UserRead
from app.services import auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
def _set_session_cookie(response: Response, token: str) -> None:
settings = get_settings()
response.set_cookie(
settings.cookie_name,
token,
max_age=settings.session_ttl_days * 86400,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
)
@router.post("/register", response_model=SessionRead, status_code=status.HTTP_201_CREATED)
async def register(
data: RegisterRequest, session: SessionDep, mailer: MailerDep, response: Response
) -> SessionRead:
user, token, expires_at = await auth_service.register(
session,
mailer,
email=data.email,
password=data.password,
display_name=data.display_name,
)
_set_session_cookie(response, token)
return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at)
@router.post("/login", response_model=SessionRead)
async def login(data: LoginRequest, session: SessionDep, response: Response) -> SessionRead:
result = await auth_service.login(session, email=data.email, password=data.password)
if result is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
user, token, expires_at = result
_set_session_cookie(response, token)
return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(request: Request, session: SessionDep, response: Response) -> None:
raw_token = extract_session_token(request)
if raw_token:
await auth_service.logout(session, raw_token=raw_token)
response.delete_cookie(get_settings().cookie_name)
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
async def verify_email(data: TokenRequest, session: SessionDep) -> None:
await auth_service.verify_email(session, raw_token=data.token)
@router.post("/request-password-reset", status_code=status.HTTP_202_ACCEPTED)
async def request_password_reset(
data: PasswordResetRequest, session: SessionDep, mailer: MailerDep
) -> dict:
await auth_service.request_password_reset(session, mailer, email=data.email)
return {"status": "accepted"}
@router.post("/reset-password", status_code=status.HTTP_204_NO_CONTENT)
async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> None:
await auth_service.reset_password(
session, raw_token=data.token, new_password=data.new_password
)
+41
View File
@@ -0,0 +1,41 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead
from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"])
@router.post(
"/{tree_id}/citations", response_model=CitationRead, status_code=status.HTTP_201_CREATED
)
async def create_citation(
tree_id: uuid.UUID, data: CitationCreate, session: SessionDep, current: CurrentUser
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.create_citation(
session, actor=current, tree=tree, **data.model_dump()
)
return CitationRead.model_validate(citation)
@router.get("/{tree_id}/citations", response_model=list[CitationRead])
async def list_citations(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[CitationRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citations = await citation_service.list_citations(session, viewer_id=current.id, tree=tree)
return [CitationRead.model_validate(c) for c in citations]
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await citation_service.delete_citation(
session, actor=current, tree=tree, citation_id=citation_id
)
+48
View File
@@ -0,0 +1,48 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.event import EventCreate, EventRead
from app.services import event_service, tree_service
router = APIRouter(prefix="/trees", tags=["events"])
@router.post("/{tree_id}/events", response_model=EventRead, status_code=status.HTTP_201_CREATED)
async def create_event(
tree_id: uuid.UUID, data: EventCreate, session: SessionDep, current: CurrentUser
) -> EventRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
event = await event_service.create_event(
session, actor=current, tree=tree, **data.model_dump()
)
return EventRead.model_validate(event)
@router.get("/{tree_id}/events", response_model=list[EventRead])
async def list_tree_events(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[EventRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
events = await event_service.list_events(session, viewer_id=current.id, tree=tree)
return [EventRead.model_validate(e) for e in events]
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
async def list_person_events(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[EventRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
events = await event_service.list_events_for_person(
session, viewer_id=current.id, tree=tree, person_id=person_id
)
return [EventRead.model_validate(e) for e in events]
@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
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await event_service.delete_event(session, actor=current, tree=tree, event_id=event_id)
+89
View File
@@ -0,0 +1,89 @@
import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead
from app.services import media_service, tree_service
def _content_url(media) -> str:
return f"/api/v1/trees/{media.tree_id}/media/{media.id}/content"
def _read(media) -> MediaRead:
out = MediaRead.model_validate(media)
# Stream through the backend (privacy-checked, browser-reachable) rather
# than expose the internal object store directly.
out.url = _content_url(media)
return out
router = APIRouter(prefix="/trees", tags=["media"])
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
async def upload_media(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
file: UploadFile = File(...),
title: str | None = Form(None),
person_id: uuid.UUID | None = Form(None),
event_id: uuid.UUID | None = Form(None),
source_id: uuid.UUID | None = Form(None),
) -> MediaRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
data = await file.read()
media = await media_service.upload_media(
session,
store,
actor=current,
tree=tree,
data=data,
filename=file.filename or "upload",
content_type=file.content_type or "application/octet-stream",
title=title,
person_id=person_id,
event_id=event_id,
source_id=source_id,
)
return _read(media)
@router.get("/{tree_id}/media", response_model=list[MediaRead])
async def list_media(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[MediaRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
return [_read(m) for m in items]
@router.get("/{tree_id}/media/{media_id}/content")
async def media_content(
tree_id: uuid.UUID,
media_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.get_media(
session, viewer_id=current.id, tree=tree, media_id=media_id
)
data = await store.get_object(key=media.storage_key)
return Response(
content=data,
media_type=media.content_type,
headers={"Content-Disposition": f'inline; filename="{media.original_filename}"'},
)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await media_service.delete_media(session, actor=current, tree=tree, media_id=media_id)
+78
View File
@@ -0,0 +1,78 @@
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, deleted: bool = False
) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
if deleted:
persons = await person_service.list_deleted_persons(
session, viewer_id=current.id, tree=tree
)
else:
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
return [PersonRead.model_validate(p) for p in persons]
@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
) -> None:
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)
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
async def restore_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.restore_person(
session, actor=current, tree=tree, person_id=person_id
)
return PersonRead.model_validate(person)
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def get_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.get_person(
session, viewer_id=current.id, tree=tree, person_id=person_id
)
return PersonRead.model_validate(person)
+59
View File
@@ -0,0 +1,59 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.relationship import RelationshipCreate, RelationshipRead
from app.services import relationship_service, tree_service
router = APIRouter(prefix="/trees", tags=["relationships"])
@router.post(
"/{tree_id}/relationships",
response_model=RelationshipRead,
status_code=status.HTTP_201_CREATED,
)
async def create_relationship(
tree_id: uuid.UUID, data: RelationshipCreate, session: SessionDep, current: CurrentUser
) -> RelationshipRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
relationship = await relationship_service.create_relationship(
session, actor=current, tree=tree, **data.model_dump()
)
return RelationshipRead.model_validate(relationship)
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
async def list_relationships(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[RelationshipRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree)
return [RelationshipRead.model_validate(r) for r in rels]
@router.get(
"/{tree_id}/persons/{person_id}/relationships",
response_model=list[RelationshipRead],
)
async def list_person_relationships(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[RelationshipRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rels = await relationship_service.list_relationships_for_person(
session, viewer_id=current.id, tree=tree, person_id=person_id
)
return [RelationshipRead.model_validate(r) for r in rels]
@router.delete(
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_relationship(
tree_id: uuid.UUID, relationship_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await relationship_service.delete_relationship(
session, actor=current, tree=tree, relationship_id=relationship_id
)
+48
View File
@@ -0,0 +1,48 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead
from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"])
@router.post("/{tree_id}/sources", response_model=SourceRead, status_code=status.HTTP_201_CREATED)
async def create_source(
tree_id: uuid.UUID, data: SourceCreate, session: SessionDep, current: CurrentUser
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.create_source(
session, actor=current, tree=tree, **data.model_dump()
)
return SourceRead.model_validate(source)
@router.get("/{tree_id}/sources", response_model=list[SourceRead])
async def list_sources(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[SourceRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
sources = await source_service.list_sources(session, viewer_id=current.id, tree=tree)
return [SourceRead.model_validate(s) for s in sources]
@router.get("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def get_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.get_source(
session, viewer_id=current.id, tree=tree, source_id=source_id
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await source_service.delete_source(session, actor=current, tree=tree, source_id=source_id)
+49
View File
@@ -0,0 +1,49 @@
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, deleted: bool = False
) -> list[TreeRead]:
if deleted:
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
else:
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)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
@router.post("/{tree_id}/restore", response_model=TreeRead)
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
return TreeRead.model_validate(tree)
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.api.deps import CurrentUser
from app.schemas.user import UserRead
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserRead)
async def read_me(current: CurrentUser) -> UserRead:
return UserRead.model_validate(current)
View File
+61
View File
@@ -0,0 +1,61 @@
"""Application configuration.
Twelve-factor: everything is read from the environment. Defaults are
development-friendly; production supplies real values via the compose `.env`.
No secrets or endpoints are hard-coded.
"""
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "Provenance"
version: str = "0.0.0"
app_env: str = Field(default="development", description="development | production")
# SQLAlchemy async URL, e.g. postgresql+asyncpg://user:pass@host:5432/db
database_url: str = Field(
default="postgresql+asyncpg://provenance:provenance@localhost:5432/provenance",
)
# --- Auth / sessions ---
session_ttl_days: int = 30
token_ttl_hours: int = 24 # email-verify / password-reset token lifetime
cookie_name: str = "provenance_session"
cookie_secure: bool = True # set false for local http; true behind TLS
# Base URL used to build links in outbound email.
app_base_url: str = "http://localhost"
# --- Object storage (S3-compatible / MinIO) ---
s3_endpoint_url: str = "http://minio:9000"
s3_bucket: str = "provenance"
s3_access_key: str = "provenance"
s3_secret_key: str = "change-me-too"
s3_region: str = "us-east-1"
s3_presign_ttl: int = 3600 # seconds
# --- Worker ---
purge_interval_seconds: int = 3600 # how often to run the soft-delete purge
purge_after_days: int = 30 # soft-deleted rows older than this are purged
# --- Email (SMTP) ---
mailer: str = Field(default="console", description="console | smtp")
smtp_host: str | None = None
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
smtp_from: str = "Provenance <no-reply@provenance.local>"
@lru_cache
def get_settings() -> Settings:
return Settings()
+43
View File
@@ -0,0 +1,43 @@
"""Async database engine, session factory, and the FastAPI session dependency.
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 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)
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
+35
View File
@@ -0,0 +1,35 @@
"""Password hashing and token primitives.
Passwords use Argon2id (argon2-cffi). Session and email tokens are random
high-entropy strings; only their SHA-256 hash is stored, so a database leak
never exposes a usable credential.
"""
import hashlib
import secrets
from argon2 import PasswordHasher
from argon2.exceptions import Argon2Error
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (Argon2Error, ValueError):
return False
def generate_token() -> str:
"""A URL-safe, high-entropy token (the raw secret handed to the client)."""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""SHA-256 of a token — what we store and look up by."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
+24
View File
@@ -0,0 +1,24 @@
"""AuthProvider interface.
Operators enable any subset of providers (local, OIDC, social). A provider's
job is narrow: verify a credential and return the matching User (or None).
Session issuance, tokens, and registration live in the auth service and are
provider-agnostic, so adding OIDC/social later (Phase 5) is additive.
"""
from abc import ABC, abstractmethod
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
class AuthProvider(ABC):
name: str
@abstractmethod
async def authenticate(
self, session: AsyncSession, *, identifier: str, secret: str
) -> User | None:
"""Return the User if the credential is valid, else None."""
raise NotImplementedError
+27
View File
@@ -0,0 +1,27 @@
"""Local (email + password) auth provider."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import verify_password
from app.integrations.auth.base import AuthProvider
from app.models.user import User
class LocalAuthProvider(AuthProvider):
name = "local"
async def authenticate(
self, session: AsyncSession, *, identifier: str, secret: str
) -> User | None:
email = identifier.strip().lower()
user = (
await session.execute(
select(User).where(User.email == email, User.deleted_at.is_(None))
)
).scalar_one_or_none()
if user is None or user.hashed_password is None:
return None
if not verify_password(user.hashed_password, secret):
return None
return user
+16
View File
@@ -0,0 +1,16 @@
"""Mailer interface for transactional email.
Implementations: ConsoleMailer (dev default — logs the link) and SMTPMailer
(operator-configured). Selected by config; resolved via app.api.deps.get_mailer.
Real deployments will move sending to the worker; for now it is inline.
"""
from abc import ABC, abstractmethod
class Mailer(ABC):
@abstractmethod
async def send_email_verification(self, *, to: str, link: str) -> None: ...
@abstractmethod
async def send_password_reset(self, *, to: str, link: str) -> None: ...
@@ -0,0 +1,16 @@
"""Development mailer: logs the would-be email (including the action link) to
stdout instead of sending. Never use in production."""
import logging
from app.integrations.mailer.base import Mailer
logger = logging.getLogger("provenance.mailer")
class ConsoleMailer(Mailer):
async def send_email_verification(self, *, to: str, link: str) -> None:
logger.info("[email] verify address for %s -> %s", to, link)
async def send_password_reset(self, *, to: str, link: str) -> None:
logger.info("[email] password reset for %s -> %s", to, link)
+43
View File
@@ -0,0 +1,43 @@
"""SMTP mailer using the standard library, run off the event loop. Configured
entirely from settings (host/port/credentials/from)."""
import asyncio
import smtplib
from email.message import EmailMessage
from app.core.config import Settings
from app.integrations.mailer.base import Mailer
class SMTPMailer(Mailer):
def __init__(self, settings: Settings) -> None:
self.settings = settings
def _send(self, *, to: str, subject: str, body: str) -> None:
msg = EmailMessage()
msg["From"] = self.settings.smtp_from
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
with smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port) as smtp:
smtp.starttls()
if self.settings.smtp_username and self.settings.smtp_password:
smtp.login(self.settings.smtp_username, self.settings.smtp_password)
smtp.send_message(msg)
async def _send_async(self, *, to: str, subject: str, body: str) -> None:
await asyncio.to_thread(self._send, to=to, subject=subject, body=body)
async def send_email_verification(self, *, to: str, link: str) -> None:
await self._send_async(
to=to,
subject="Verify your Provenance email",
body=f"Confirm your email address:\n\n{link}\n",
)
async def send_password_reset(self, *, to: str, link: str) -> None:
await self._send_async(
to=to,
subject="Reset your Provenance password",
body=f"Reset your password:\n\n{link}\n",
)
@@ -0,0 +1,25 @@
"""ObjectStore interface — pluggable binary storage behind the service layer.
Implementations are S3-compatible (MinIO for self-host, any S3 otherwise).
Methods are async wrappers so the service layer stays non-blocking even though
the underlying SDK (boto3) is synchronous.
"""
from abc import ABC, abstractmethod
class ObjectStore(ABC):
@abstractmethod
async def ensure_bucket(self) -> None: ...
@abstractmethod
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
@abstractmethod
async def get_object(self, *, key: str) -> bytes: ...
@abstractmethod
async def presigned_get_url(self, *, key: str) -> str: ...
@abstractmethod
async def delete_object(self, *, key: str) -> None: ...
@@ -0,0 +1,63 @@
"""S3-compatible ObjectStore (boto3), suitable for MinIO or any S3 provider.
boto3 is synchronous; each call is dispatched to a thread so request handlers
and the worker stay async."""
import asyncio
import boto3
from botocore.client import Config
from botocore.exceptions import ClientError
from app.core.config import Settings
from app.integrations.objectstore.base import ObjectStore
class S3ObjectStore(ObjectStore):
def __init__(self, settings: Settings) -> None:
self.bucket = settings.s3_bucket
self.presign_ttl = settings.s3_presign_ttl
self._client = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name=settings.s3_region,
config=Config(signature_version="s3v4"),
)
def _ensure_bucket_sync(self) -> None:
try:
self._client.head_bucket(Bucket=self.bucket)
except ClientError:
self._client.create_bucket(Bucket=self.bucket)
async def ensure_bucket(self) -> None:
await asyncio.to_thread(self._ensure_bucket_sync)
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
await asyncio.to_thread(
self._client.put_object,
Bucket=self.bucket,
Key=key,
Body=data,
ContentType=content_type,
)
async def get_object(self, *, key: str) -> bytes:
def _get() -> bytes:
obj = self._client.get_object(Bucket=self.bucket, Key=key)
return obj["Body"].read()
return await asyncio.to_thread(_get)
async def presigned_get_url(self, *, key: str) -> str:
return await asyncio.to_thread(
self._client.generate_presigned_url,
"get_object",
Params={"Bucket": self.bucket, "Key": key},
ExpiresIn=self.presign_ttl,
)
async def delete_object(self, *, key: str) -> None:
await asyncio.to_thread(self._client.delete_object, Bucket=self.bucket, Key=key)
+61
View File
@@ -0,0 +1,61 @@
"""FastAPI application entrypoint.
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.
"""
import logging
import sys
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 _configure_logging() -> None:
"""Emit the app's own ``provenance.*`` logs at INFO to stdout (uvicorn only
configures its own loggers). The ConsoleMailer relies on this so self-hosters
can read verification/reset links from the logs."""
app_logger = logging.getLogger("provenance")
app_logger.setLevel(logging.INFO)
if not app_logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s"))
app_logger.addHandler(handler)
app_logger.propagate = False
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:
_configure_logging()
settings = get_settings()
app = FastAPI(
title=settings.app_name,
version=settings.version,
description="Provenance API — family and land provenance.",
)
app.include_router(health_router)
app.include_router(api_router)
_register_error_handlers(app)
return app
app = create_app()
+33
View File
@@ -0,0 +1,33 @@
"""Import every model so ``Base.metadata`` is complete for Alembic autogenerate
and for ``create_all`` in tests."""
from app.models.audit import AuditEntry
from app.models.auth import Session, UserToken
from app.models.base import Base
from app.models.event import Event
from app.models.media import Media
from app.models.person import Name, Person
from app.models.place import Place, PlaceName
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree, TreeMembership
from app.models.user import User
__all__ = [
"Base",
"User",
"Tree",
"TreeMembership",
"Person",
"Name",
"Place",
"PlaceName",
"Relationship",
"Event",
"Source",
"Citation",
"AuditEntry",
"Session",
"UserToken",
"Media",
]
+42
View File
@@ -0,0 +1,42 @@
"""AuditEntry — append-only, immutable record of every mutation.
The actor is a User, or the assistant principal acting *on behalf of* a User
(actor_type = assistant, actor_user_id = the user it serves). No timestamps
mixin and no soft delete: this table is never updated or deleted.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy import Enum as SAEnum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.enums import AuditActorType
from app.models.mixins import UUIDPrimaryKey
class AuditEntry(Base, UUIDPrimaryKey):
__tablename__ = "audit_entries"
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
tree_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("trees.id", ondelete="SET NULL"), index=True
)
actor_type: Mapped[AuditActorType] = mapped_column(
SAEnum(AuditActorType, name="audit_actor_type"),
default=AuditActorType.user,
server_default=AuditActorType.user.value,
)
actor_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
action: Mapped[str] = mapped_column(String(64)) # create | update | delete | restore | ...
entity_type: Mapped[str] = mapped_column(String(64))
entity_id: Mapped[uuid.UUID | None] = mapped_column()
before: Mapped[dict | None] = mapped_column(JSONB)
after: Mapped[dict | None] = mapped_column(JSONB)
+43
View File
@@ -0,0 +1,43 @@
"""Authentication state: opaque backend-issued sessions and single-use email
tokens. Only token *hashes* are stored (see app.core.security).
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.enums import TokenPurpose
from app.models.mixins import UUIDPrimaryKey
class Session(Base, UUIDPrimaryKey):
__tablename__ = "sessions"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class UserToken(Base, UUIDPrimaryKey):
__tablename__ = "user_tokens"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
purpose: Mapped[TokenPurpose] = mapped_column(SAEnum(TokenPurpose, name="token_purpose"))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+20
View File
@@ -0,0 +1,20 @@
"""Declarative base with a stable constraint-naming convention.
A fixed naming convention is important so Alembic generates deterministic,
human-readable names for indexes/constraints across migrations.
"""
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
+62
View File
@@ -0,0 +1,62 @@
"""Closed-set enumerations that drive logic (authorization, privacy, traversal).
Open-ended, GEDCOM-extensible vocabularies (event type, name type, source type)
are stored as strings instead, so importing real-world files never fails on an
unknown tag.
"""
import enum
class TreeVisibility(enum.StrEnum):
public = "public"
unlisted = "unlisted"
private = "private"
class MembershipRole(enum.StrEnum):
owner = "owner"
editor = "editor"
viewer = "viewer"
class PersonPrivacy(enum.StrEnum):
"""Per-person override of the tree's visibility (PRD US-041)."""
inherit = "inherit"
private = "private"
public = "public"
class RelationshipType(enum.StrEnum):
parent_child = "parent_child"
partnership = "partnership"
sibling = "sibling"
class ParentChildQualifier(enum.StrEnum):
"""Qualifies a parent_child edge so adoption/donor/blended families are
first-class rather than edge cases (ARCHITECTURE §5)."""
biological = "biological"
adoptive = "adoptive"
step = "step"
foster = "foster"
donor = "donor"
guardian = "guardian"
class CitationConfidence(enum.StrEnum):
high = "high"
medium = "medium"
low = "low"
class AuditActorType(enum.StrEnum):
user = "user"
assistant = "assistant"
class TokenPurpose(enum.StrEnum):
email_verify = "email_verify"
password_reset = "password_reset"
+51
View File
@@ -0,0 +1,51 @@
"""Event — a typed, dated, placed fact attached to a Person or a partnership.
Genealogical dates are messy, so we keep both:
- ``date_value`` — the original string, verbatim (e.g. "ABT 1850", "BET 1850 AND
1855"), for fidelity and GEDCOM round-trip.
- ``date_start`` / ``date_end`` — a normalized range for sorting and filtering
(an exact date sets start == end).
A CHECK enforces that exactly one subject (person XOR relationship) is set.
"""
import uuid
from datetime import date
from sqlalchemy import CheckConstraint, Date, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class Event(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "events"
__table_args__ = (
CheckConstraint(
"(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)",
name="subject_person_xor_relationship",
),
)
# Open vocabulary (birth, death, marriage, residence, immigration, ...).
event_type: Mapped[str] = mapped_column(String(64), index=True)
person_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True
)
relationship_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("relationships.id", ondelete="CASCADE"), index=True
)
place_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("places.id", ondelete="SET NULL"), index=True
)
date_value: Mapped[str | None] = mapped_column(String(255))
date_start: Mapped[date | None] = mapped_column(Date)
date_end: Mapped[date | None] = mapped_column(Date)
date_precision: Mapped[str | None] = mapped_column(String(32)) # exact|about|before|after|range
calendar: Mapped[str] = mapped_column(
String(32), default="gregorian", server_default="gregorian"
)
detail: Mapped[str | None] = mapped_column(String(512)) # e.g. occupation, address
notes: Mapped[str | None] = mapped_column(Text)
+36
View File
@@ -0,0 +1,36 @@
"""Media — a binary asset (image, scan, PDF, audio) in object storage. The row
holds metadata + checksum + the storage key; the bytes live in the ObjectStore.
Optionally attached to a single fact (person, event, or source) for now."""
import uuid
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class Media(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "media"
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
storage_key: Mapped[str] = mapped_column(String(512), unique=True)
original_filename: Mapped[str] = mapped_column(String(512))
content_type: Mapped[str] = mapped_column(String(128))
byte_size: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str] = mapped_column(String(64), index=True)
title: Mapped[str | None] = mapped_column(String(512))
# Optional single attachment target.
person_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("persons.id", ondelete="SET NULL"), index=True
)
event_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("events.id", ondelete="SET NULL"), index=True
)
source_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("sources.id", ondelete="SET NULL"), index=True
)
+45
View File
@@ -0,0 +1,45 @@
"""Reusable column mixins.
- ``UUIDPrimaryKey`` — UUID surrogate key (no PII in URLs; safe for multi-tenant).
- ``Timestamps`` — created/updated audit timestamps (DB-managed).
- ``SoftDelete`` — ``deleted_at``; a row is "deleted" when set. A scheduled
worker purges rows past the 30-day window (PRD US-080/081).
- ``TenantScoped`` — ``tree_id`` FK; every tree-owned row carries it so the
privacy engine can enforce isolation uniformly.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
class UUIDPrimaryKey:
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
class Timestamps:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
class SoftDelete:
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
class TenantScoped:
@declared_attr
def tree_id(cls) -> Mapped[uuid.UUID]: # noqa: N805
return mapped_column(
ForeignKey("trees.id", ondelete="CASCADE"), nullable=False, index=True
)
+52
View File
@@ -0,0 +1,52 @@
"""Person and Name.
A Person carries living/deceased status and a per-person privacy override; the
display identity lives in one or more Name rows (variants, married names,
aliases) so name changes over time are first-class.
"""
import uuid
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.enums import PersonPrivacy
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "persons"
# Free-form to stay inclusive; not a closed enum.
gender: Mapped[str | None] = mapped_column(String(32))
# NULL = unknown (let the living-person rule derive it); True/False = asserted.
is_living: Mapped[bool | None] = mapped_column(Boolean)
privacy: Mapped[PersonPrivacy] = mapped_column(
SAEnum(PersonPrivacy, name="person_privacy"),
default=PersonPrivacy.inherit,
server_default=PersonPrivacy.inherit.value,
)
notes: Mapped[str | None] = mapped_column(Text)
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "names"
person_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True
)
# Open vocabulary (birth, married, alias, religious, ...) for GEDCOM fidelity.
name_type: Mapped[str] = mapped_column(String(32), default="birth", server_default="birth")
given: Mapped[str | None] = mapped_column(String(255))
surname: Mapped[str | None] = mapped_column(String(255))
prefix: Mapped[str | None] = mapped_column(String(64))
suffix: Mapped[str | None] = mapped_column(String(64))
nickname: Mapped[str | None] = mapped_column(String(128))
# Original full form preserved verbatim (round-trip fidelity).
display_name: Mapped[str | None] = mapped_column(String(512))
is_primary: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false")
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+42
View File
@@ -0,0 +1,42 @@
"""Place — a gazetteer entity — and PlaceName, its historical name variants.
PlaceName carries date ranges so a record entered as "Königsberg, 1900" sorts
and displays correctly against "Kaliningrad" (ARCHITECTURE §5, §10).
Phase 0 scopes Place to a Tree (``tree_id``) to keep tenant isolation absolute.
ARCHITECTURE calls the gazetteer "tenant-shared"; a deployment-wide shared
gazetteer is a deliberate later refinement (see ARCHITECTURE §5 note).
"""
import uuid
from datetime import date
from sqlalchemy import Date, Float, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class Place(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "places"
name: Mapped[str] = mapped_column(String(512))
# Self-referential hierarchy: place within place.
parent_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("places.id", ondelete="SET NULL"), index=True
)
place_type: Mapped[str | None] = mapped_column(String(64))
latitude: Mapped[float | None] = mapped_column(Float)
longitude: Mapped[float | None] = mapped_column(Float)
class PlaceName(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "place_names"
place_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("places.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(512))
valid_from: Mapped[date | None] = mapped_column(Date)
valid_to: Mapped[date | None] = mapped_column(Date)
+40
View File
@@ -0,0 +1,40 @@
"""Relationship — a typed, qualified edge between two Persons.
Modeling parentage as qualified edges (rather than assuming two biological
parents) is what makes adoption, donor conception, and blended families
first-class. ``qualifier`` applies to parent_child edges; partnership events
(marriage, divorce) attach to the Relationship via Event.relationship_id.
"""
import uuid
from sqlalchemy import CheckConstraint, ForeignKey, Text
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.enums import ParentChildQualifier, RelationshipType
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class Relationship(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "relationships"
__table_args__ = (
CheckConstraint("person_from_id <> person_to_id", name="different_persons"),
)
type: Mapped[RelationshipType] = mapped_column(
SAEnum(RelationshipType, name="relationship_type")
)
# For parent_child: from = parent, to = child. For partnership/sibling: symmetric.
person_from_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True
)
person_to_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True
)
# Only meaningful for parent_child edges.
qualifier: Mapped[ParentChildQualifier | None] = mapped_column(
SAEnum(ParentChildQualifier, name="parent_child_qualifier")
)
notes: Mapped[str | None] = mapped_column(Text)
+66
View File
@@ -0,0 +1,66 @@
"""Source and Citation — the first-class provenance spine.
A Source is a reusable record of an origin; a Citation links one Source to one
specific fact (a Person, Name, Event, or Relationship — and OwnershipEvent once
property lands). A CHECK enforces exactly one target so a citation always points
at a single fact.
"""
import uuid
from sqlalchemy import CheckConstraint, ForeignKey, String, Text
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.enums import CitationConfidence
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class Source(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "sources"
title: Mapped[str] = mapped_column(String(512))
author: Mapped[str | None] = mapped_column(String(255))
source_type: Mapped[str | None] = mapped_column(String(64)) # book, census, deed, ...
repository: Mapped[str | None] = mapped_column(String(255))
url: Mapped[str | None] = mapped_column(String(1024))
citation_text: Mapped[str | None] = mapped_column(Text)
publication_info: Mapped[str | None] = mapped_column(Text)
quality_note: Mapped[str | None] = mapped_column(String(255))
class Citation(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "citations"
__table_args__ = (
CheckConstraint(
"(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int "
"+ (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1",
name="exactly_one_target",
),
)
source_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("sources.id", ondelete="CASCADE"), index=True
)
# Exactly one of these is set (see CHECK above).
person_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True
)
event_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("events.id", ondelete="CASCADE"), index=True
)
name_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("names.id", ondelete="CASCADE"), index=True
)
relationship_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("relationships.id", ondelete="CASCADE"), index=True
)
# Locality within the source.
page: Mapped[str | None] = mapped_column(String(255))
detail: Mapped[str | None] = mapped_column(Text) # entry, line, free notes
confidence: Mapped[CitationConfidence | None] = mapped_column(
SAEnum(CitationConfidence, name="citation_confidence")
)
+43
View File
@@ -0,0 +1,43 @@
"""Tree — the top-level tenant boundary for genealogical data — and
TreeMembership, the basis for authorization (ARCHITECTURE §5).
"""
import uuid
from sqlalchemy import Enum as SAEnum
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.enums import MembershipRole, TreeVisibility
from app.models.mixins import SoftDelete, Timestamps, UUIDPrimaryKey
class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
__tablename__ = "trees"
owner_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"), index=True
)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text)
visibility: Mapped[TreeVisibility] = mapped_column(
SAEnum(TreeVisibility, name="tree_visibility"),
default=TreeVisibility.private,
server_default=TreeVisibility.private.value,
)
class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
__tablename__ = "tree_memberships"
__table_args__ = (
UniqueConstraint("tree_id", "user_id", name="uq_tree_memberships_tree_user"),
)
tree_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("trees.id", ondelete="CASCADE"), index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
role: Mapped[MembershipRole] = mapped_column(SAEnum(MembershipRole, name="membership_role"))
+21
View File
@@ -0,0 +1,21 @@
"""User — a person with login. Identity is internal so one user can link
multiple auth providers later (the provider-link table arrives with the auth
slice). ``hashed_password`` is nullable: external/OIDC users have none.
"""
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.mixins import SoftDelete, Timestamps, UUIDPrimaryKey
class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
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))
+40
View File
@@ -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
View File
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserRead
class RegisterRequest(BaseModel):
email: str
password: str = Field(min_length=8)
display_name: str | None = None
class LoginRequest(BaseModel):
email: str
password: str
class TokenRequest(BaseModel):
token: str
class PasswordResetRequest(BaseModel):
email: str
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(min_length=8)
class SessionRead(BaseModel):
user: UserRead
token: str
expires_at: datetime
+39
View File
@@ -0,0 +1,39 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class EventCreate(BaseModel):
event_type: str
# Exactly one subject: a person or a partnership (relationship).
person_id: uuid.UUID | None = None
relationship_id: uuid.UUID | None = None
place_id: uuid.UUID | None = None
# Verbatim date string (e.g. "ABT 1850") and/or a normalized range.
date_value: str | None = None
date_start: date | None = None
date_end: date | None = None
date_precision: str | None = None
calendar: str = "gregorian"
detail: str | None = None
notes: str | None = None
class EventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
event_type: str
person_id: uuid.UUID | None
relationship_id: uuid.UUID | None
place_id: uuid.UUID | None
date_value: str | None
date_start: date | None
date_end: date | None
date_precision: str | None
calendar: str
detail: str | None
notes: str | None
created_at: datetime
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class MediaRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
original_filename: str
content_type: str
byte_size: int
checksum_sha256: str
title: str | None
person_id: uuid.UUID | None
event_id: uuid.UUID | None
source_id: uuid.UUID | None
created_at: datetime
# Presigned download URL, filled in by the router from the ObjectStore.
url: str | None = None
+27
View File
@@ -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
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import ParentChildQualifier, RelationshipType
class RelationshipCreate(BaseModel):
type: RelationshipType
person_from_id: uuid.UUID
person_to_id: uuid.UUID
# Only meaningful for parent_child edges (from = parent, to = child).
qualifier: ParentChildQualifier | None = None
notes: str | None = None
class RelationshipRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
type: RelationshipType
person_from_id: uuid.UUID
person_to_id: uuid.UUID
qualifier: ParentChildQualifier | None
notes: str | None
created_at: datetime
+61
View File
@@ -0,0 +1,61 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import CitationConfidence
class SourceCreate(BaseModel):
title: str
author: str | None = None
source_type: str | None = None
repository: str | None = None
url: str | None = None
citation_text: str | None = None
publication_info: str | None = None
quality_note: str | None = None
class SourceRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
title: str
author: str | None
source_type: str | None
repository: str | None
url: str | None
citation_text: str | None
publication_info: str | None
quality_note: str | None
created_at: datetime
class CitationCreate(BaseModel):
source_id: uuid.UUID
# Exactly one target fact.
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
name_id: uuid.UUID | None = None
relationship_id: uuid.UUID | None = None
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
source_id: uuid.UUID
person_id: uuid.UUID | None
event_id: uuid.UUID | None
name_id: uuid.UUID | None
relationship_id: uuid.UUID | None
page: str | None
detail: str | None
confidence: CitationConfidence | None
created_at: datetime
+23
View File
@@ -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
+22
View File
@@ -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
View File
+37
View File
@@ -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
+201
View File
@@ -0,0 +1,201 @@
"""Authentication service: registration, login, sessions, email verification,
and password reset. Provider-agnostic — credential checking is delegated to an
AuthProvider; this module owns session/token issuance and the audit trail.
"""
from datetime import UTC, datetime, timedelta
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.security import generate_token, hash_password, hash_token
from app.integrations.auth.local import LocalAuthProvider
from app.integrations.mailer.base import Mailer
from app.models.auth import Session as SessionModel
from app.models.auth import UserToken
from app.models.enums import TokenPurpose
from app.models.user import User
from app.services.audit import record_audit
from app.services.exceptions import Conflict, NotFound
_local_provider = LocalAuthProvider()
def _now() -> datetime:
return datetime.now(UTC)
def _link(path: str, raw: str) -> str:
return f"{get_settings().app_base_url}{path}?token={raw}"
def _issue_session(session: AsyncSession, user: User) -> tuple[str, SessionModel]:
raw = generate_token()
record = SessionModel(
user_id=user.id,
token_hash=hash_token(raw),
expires_at=_now() + timedelta(days=get_settings().session_ttl_days),
)
session.add(record)
return raw, record
def _create_email_token(session: AsyncSession, user: User, purpose: TokenPurpose) -> str:
raw = generate_token()
session.add(
UserToken(
user_id=user.id,
purpose=purpose,
token_hash=hash_token(raw),
expires_at=_now() + timedelta(hours=get_settings().token_ttl_hours),
)
)
return raw
async def _consume_token(
session: AsyncSession, raw_token: str, purpose: TokenPurpose
) -> UserToken:
token = (
await session.execute(
select(UserToken).where(
UserToken.token_hash == hash_token(raw_token),
UserToken.purpose == purpose,
)
)
).scalar_one_or_none()
if token is None or token.used_at is not None or token.expires_at <= _now():
raise NotFound("invalid or expired token")
token.used_at = _now()
return token
async def register(
session: AsyncSession,
mailer: Mailer,
*,
email: str,
password: str,
display_name: str | None = None,
) -> tuple[User, str, datetime]:
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, hashed_password=hash_password(password))
session.add(user)
await session.flush()
verify_raw = _create_email_token(session, user, TokenPurpose.email_verify)
raw_token, record = _issue_session(session, user)
record_audit(
session,
action="register",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
after={"email": email},
)
await session.commit()
await session.refresh(user)
await mailer.send_email_verification(to=email, link=_link("/auth/verify-email", verify_raw))
return user, raw_token, record.expires_at
async def login(
session: AsyncSession, *, email: str, password: str
) -> tuple[User, str, datetime] | None:
user = await _local_provider.authenticate(session, identifier=email, secret=password)
if user is None:
return None
raw_token, record = _issue_session(session, user)
record_audit(
session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id
)
await session.commit()
return user, raw_token, record.expires_at
async def logout(session: AsyncSession, *, raw_token: str) -> None:
await session.execute(
update(SessionModel)
.where(
SessionModel.token_hash == hash_token(raw_token),
SessionModel.revoked_at.is_(None),
)
.values(revoked_at=_now())
)
await session.commit()
async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User | None:
record = (
await session.execute(
select(SessionModel).where(SessionModel.token_hash == hash_token(raw_token))
)
).scalar_one_or_none()
if record is None or record.revoked_at is not None or record.expires_at <= _now():
return None
return (
await session.execute(
select(User).where(User.id == record.user_id, User.deleted_at.is_(None))
)
).scalar_one_or_none()
async def verify_email(session: AsyncSession, *, raw_token: str) -> None:
token = await _consume_token(session, raw_token, TokenPurpose.email_verify)
await session.execute(
update(User).where(User.id == token.user_id).values(email_verified_at=_now())
)
record_audit(
session,
action="verify_email",
entity_type="User",
entity_id=token.user_id,
actor_user_id=token.user_id,
)
await session.commit()
async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email: str) -> None:
email = email.strip().lower()
user = (
await session.execute(
select(User).where(User.email == email, User.deleted_at.is_(None))
)
).scalar_one_or_none()
# Always succeed to avoid leaking which emails are registered.
if user is None:
return
raw = _create_email_token(session, user, TokenPurpose.password_reset)
await session.commit()
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
await session.execute(
update(User)
.where(User.id == token.user_id)
.values(hashed_password=hash_password(new_password))
)
# Revoke all existing sessions — a reset invalidates prior logins.
await session.execute(
update(SessionModel)
.where(SessionModel.user_id == token.user_id, SessionModel.revoked_at.is_(None))
.values(revoked_at=_now())
)
record_audit(
session,
action="reset_password",
entity_type="User",
entity_id=token.user_id,
actor_user_id=token.user_id,
)
await session.commit()
+141
View File
@@ -0,0 +1,141 @@
"""Citation service. A citation links one Source to exactly one fact (person,
event, name, or relationship) within a tree — the provenance spine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import CitationConfidence
from app.models.event import Event
from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.source import Citation, Source
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 Conflict, Forbidden, NotFound
# Citation target column -> model, for tenant/existence validation.
_TARGET_MODELS = {
"person_id": Person,
"event_id": Event,
"name_id": Name,
"relationship_id": Relationship,
}
async def _in_tree(session: AsyncSession, model: type, id_: uuid.UUID, tree_id: uuid.UUID) -> bool:
row = (
await session.execute(
select(model.id).where(
model.id == id_, model.tree_id == tree_id, model.deleted_at.is_(None)
)
)
).scalar_one_or_none()
return row is not None
async def create_citation(
session: AsyncSession,
*,
actor: User,
tree: Tree,
source_id: uuid.UUID,
person_id: uuid.UUID | None = None,
event_id: uuid.UUID | None = None,
name_id: uuid.UUID | None = None,
relationship_id: uuid.UUID | None = None,
page: str | None = None,
detail: str | None = None,
confidence: CitationConfidence | None = None,
) -> Citation:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
targets = {
"person_id": person_id,
"event_id": event_id,
"name_id": name_id,
"relationship_id": relationship_id,
}
set_targets = {k: v for k, v in targets.items() if v is not None}
if len(set_targets) != 1:
raise Conflict("a citation must reference exactly one fact")
if not await _in_tree(session, Source, source_id, tree.id):
raise NotFound("source not found in this tree")
(target_col, target_id), = set_targets.items()
if not await _in_tree(session, _TARGET_MODELS[target_col], target_id, tree.id):
raise NotFound("cited fact not found in this tree")
citation = Citation(
tree_id=tree.id,
source_id=source_id,
person_id=person_id,
event_id=event_id,
name_id=name_id,
relationship_id=relationship_id,
page=page,
detail=detail,
confidence=confidence,
)
session.add(citation)
await session.flush()
record_audit(
session,
action="create",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"source_id": str(source_id), target_col: str(target_id)},
)
await session.commit()
await session.refresh(citation)
return citation
async def list_citations(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Citation]:
"""All citations in the tree — the UI maps them to facts to show 'sourced'
indicators in a single round-trip."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Citation)
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
.order_by(Citation.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_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")
citation = (
await session.execute(
select(Citation).where(
Citation.id == citation_id,
Citation.tree_id == tree.id,
Citation.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if citation is None:
raise NotFound("citation not found")
citation.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+150
View File
@@ -0,0 +1,150 @@
"""Event service. Writes require editor rights; reads go through the privacy
engine. Every event has exactly one subject — a Person or a partnership."""
import uuid
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.event import Event
from app.models.person import Person
from app.models.place import Place
from app.models.relationship import Relationship
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 Conflict, Forbidden, NotFound
async def _belongs_to_tree(
session: AsyncSession, model: type, id_: uuid.UUID, tree_id: uuid.UUID
) -> bool:
row = (
await session.execute(
select(model.id).where(
model.id == id_, model.tree_id == tree_id, model.deleted_at.is_(None)
)
)
).scalar_one_or_none()
return row is not None
async def create_event(
session: AsyncSession,
*,
actor: User,
tree: Tree,
event_type: str,
person_id: uuid.UUID | None = None,
relationship_id: uuid.UUID | 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 = "gregorian",
detail: str | None = None,
notes: str | None = None,
) -> Event:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
if bool(person_id) == bool(relationship_id):
raise Conflict("an event needs exactly one subject: person_id or relationship_id")
if person_id and not await _belongs_to_tree(session, Person, person_id, tree.id):
raise NotFound("person not found in this tree")
if relationship_id and not await _belongs_to_tree(
session, Relationship, relationship_id, tree.id
):
raise NotFound("relationship not found in this tree")
if place_id and not await _belongs_to_tree(session, Place, place_id, tree.id):
raise NotFound("place not found in this tree")
event = Event(
tree_id=tree.id,
event_type=event_type,
person_id=person_id,
relationship_id=relationship_id,
place_id=place_id,
date_value=date_value,
date_start=date_start,
date_end=date_end,
date_precision=date_precision,
calendar=calendar,
detail=detail,
notes=notes,
)
session.add(event)
await session.flush()
record_audit(
session,
action="create",
entity_type="Event",
entity_id=event.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"event_type": event_type, "person_id": str(person_id) if person_id else None},
)
await session.commit()
await session.refresh(event)
return event
async def list_events(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Event]:
"""All events in the tree — lets the family view compute birth/death years."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Event)
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
.order_by(Event.date_start.nulls_last(), Event.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_events_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Event]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Event)
.where(
Event.tree_id == tree.id,
Event.person_id == person_id,
Event.deleted_at.is_(None),
)
.order_by(Event.date_start.nulls_last(), Event.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def delete_event(
session: AsyncSession, *, actor: User, tree: Tree, event_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")
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")
from datetime import UTC, datetime
event.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Event",
entity_id=event.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+18
View File
@@ -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)."""
+124
View File
@@ -0,0 +1,124 @@
"""Media service. Bytes go to the ObjectStore; a metadata row goes to the DB.
Writes require editor rights; reads go through the privacy engine."""
import hashlib
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.objectstore.base import ObjectStore
from app.models.media import Media
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 upload_media(
session: AsyncSession,
store: ObjectStore,
*,
actor: User,
tree: Tree,
data: bytes,
filename: str,
content_type: str,
title: str | None = None,
person_id: uuid.UUID | None = None,
event_id: uuid.UUID | None = None,
source_id: uuid.UUID | None = None,
) -> Media:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media_id = uuid.uuid4()
key = f"{tree.id}/{media_id}/{filename}"
await store.ensure_bucket()
await store.put_object(key=key, data=data, content_type=content_type)
media = Media(
id=media_id,
tree_id=tree.id,
uploader_id=actor.id,
storage_key=key,
original_filename=filename,
content_type=content_type,
byte_size=len(data),
checksum_sha256=hashlib.sha256(data).hexdigest(),
title=title,
person_id=person_id,
event_id=event_id,
source_id=source_id,
)
session.add(media)
await session.flush()
record_audit(
session,
action="create",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"filename": filename, "bytes": len(data)},
)
await session.commit()
await session.refresh(media)
return media
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Media)
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
.order_by(Media.created_at.desc())
)
return list((await session.execute(stmt)).scalars().all())
async def get_media(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, media_id: uuid.UUID
) -> Media:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
return media
async def delete_media(
session: AsyncSession, *, actor: User, tree: Tree, media_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")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
# Soft delete the row; the object is removed by the worker's purge job.
media.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+211
View File
@@ -0,0 +1,211 @@
"""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 datetime import UTC, datetime
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, NotFound
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 get_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> Person:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view 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")
# Run the single person through the privacy engine (redaction lands Phase 2).
if (
await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
== Visibility.hidden
):
raise NotFound("person not found")
await _attach_primary_name(session, person)
return person
async def delete_person(
session: AsyncSession, *, actor: User, tree: Tree, person_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")
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")
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,
)
await session.commit()
async def restore_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> 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_not(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("deleted person not found")
person.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def list_deleted_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_not(None))
.order_by(Person.deleted_at.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
for person in persons:
await _attach_primary_name(session, person)
return persons
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
+62
View File
@@ -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
@@ -0,0 +1,135 @@
"""Relationship service. Typed, qualified edges between two Persons in a tree.
Writes require editor rights; reads go through the privacy engine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ParentChildQualifier, RelationshipType
from app.models.person import Person
from app.models.relationship import Relationship
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 Conflict, Forbidden, NotFound
async def _person_in_tree(session: AsyncSession, person_id: uuid.UUID, tree_id: uuid.UUID) -> bool:
row = (
await session.execute(
select(Person.id).where(
Person.id == person_id, Person.tree_id == tree_id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
return row is not None
async def create_relationship(
session: AsyncSession,
*,
actor: User,
tree: Tree,
type: RelationshipType,
person_from_id: uuid.UUID,
person_to_id: uuid.UUID,
qualifier: ParentChildQualifier | None = None,
notes: str | None = None,
) -> Relationship:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
if person_from_id == person_to_id:
raise Conflict("a relationship needs two different people")
if qualifier is not None and type is not RelationshipType.parent_child:
raise Conflict("qualifier only applies to parent_child relationships")
for pid in (person_from_id, person_to_id):
if not await _person_in_tree(session, pid, tree.id):
raise NotFound("person not found in this tree")
relationship = Relationship(
tree_id=tree.id,
type=type,
person_from_id=person_from_id,
person_to_id=person_to_id,
qualifier=qualifier,
notes=notes,
)
session.add(relationship)
await session.flush()
record_audit(
session,
action="create",
entity_type="Relationship",
entity_id=relationship.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"type": type.value, "from": str(person_from_id), "to": str(person_to_id)},
)
await session.commit()
await session.refresh(relationship)
return relationship
async def list_relationships(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Relationship]:
"""All relationships in the tree — powers the family/pedigree view in one call."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Relationship)
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
.order_by(Relationship.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_relationships_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Relationship]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
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,
),
)
.order_by(Relationship.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def delete_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_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")
relationship = (
await session.execute(
select(Relationship).where(
Relationship.id == relationship_id,
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if relationship is None:
raise NotFound("relationship not found")
relationship.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Relationship",
entity_id=relationship.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+112
View File
@@ -0,0 +1,112 @@
"""Source service. Sources are reusable, tree-scoped records of an origin.
Writes require editor rights; reads go through the privacy engine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.source import Source
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 create_source(
session: AsyncSession,
*,
actor: User,
tree: Tree,
title: str,
author: str | None = None,
source_type: str | None = None,
repository: str | None = None,
url: str | None = None,
citation_text: str | None = None,
publication_info: str | None = None,
quality_note: str | None = None,
) -> Source:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = Source(
tree_id=tree.id,
title=title,
author=author,
source_type=source_type,
repository=repository,
url=url,
citation_text=citation_text,
publication_info=publication_info,
quality_note=quality_note,
)
session.add(source)
await session.flush()
record_audit(
session,
action="create",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"title": title},
)
await session.commit()
await session.refresh(source)
return source
async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Source]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Source)
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
.order_by(Source.title)
)
return list((await session.execute(stmt)).scalars().all())
async def get_source(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, source_id: uuid.UUID
) -> Source:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
return source
async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_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")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
source.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+114
View File
@@ -0,0 +1,114 @@
"""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 datetime import UTC, datetime
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
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
"""Load a tree (including soft-deleted) and require the actor be its owner."""
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
if tree is None:
raise NotFound("tree not found")
role = await privacy.get_membership_role(session, actor.id, tree.id)
if role is not MembershipRole.owner:
raise Forbidden("only the owner can delete or restore a tree")
return tree
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is None:
tree.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is not None:
tree.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
return tree
async def list_deleted_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_not(None))
.order_by(Tree.deleted_at.desc())
)
return list((await session.execute(stmt)).scalars().all())
+44
View File
@@ -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)
+103
View File
@@ -0,0 +1,103 @@
"""Background worker. Same image as the backend, run in worker mode
(`python -m app.worker`). First job: the scheduled soft-delete purge — hard-
delete rows whose ``deleted_at`` is older than the recovery window, and remove
their objects from storage. More jobs (media processing, scraping, hints) and a
proper queue arrive in later phases.
"""
import asyncio
import logging
import sys
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from app.core.config import get_settings
from app.core.db import get_sessionmaker
from app.integrations.objectstore.s3 import S3ObjectStore
from app.models import (
Citation,
Event,
Media,
Name,
Person,
Place,
PlaceName,
Relationship,
Source,
Tree,
User,
)
logger = logging.getLogger("provenance.worker")
# Child -> parent so foreign keys are satisfied as rows are removed.
_PURGE_ORDER = [Citation, Name, Event, Relationship, PlaceName, Place, Source, Person, Tree, User]
async def _purge_media(sessionmaker, store, cutoff: datetime) -> None:
async with sessionmaker() as session:
rows = (
await session.execute(
select(Media).where(Media.deleted_at.is_not(None), Media.deleted_at < cutoff)
)
).scalars().all()
for media in rows:
try:
await store.delete_object(key=media.storage_key)
except Exception as exc: # noqa: BLE001
logger.warning("object delete failed for %s: %s", media.storage_key, exc)
await session.delete(media)
await session.commit()
if rows:
logger.info("purged %d media", len(rows))
async def _purge_table(sessionmaker, model, cutoff: datetime) -> None:
async with sessionmaker() as session:
try:
res = await session.execute(
delete(model).where(model.deleted_at.is_not(None), model.deleted_at < cutoff)
)
await session.commit()
if res.rowcount:
logger.info("purged %d %s", res.rowcount, model.__tablename__)
except Exception as exc: # noqa: BLE001
await session.rollback()
logger.warning("purge %s failed: %s", model.__tablename__, exc)
async def purge_once(sessionmaker, store) -> None:
settings = get_settings()
cutoff = datetime.now(UTC) - timedelta(days=settings.purge_after_days)
await _purge_media(sessionmaker, store, cutoff)
for model in _PURGE_ORDER:
await _purge_table(sessionmaker, model, cutoff)
async def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s", stream=sys.stdout
)
settings = get_settings()
store = S3ObjectStore(settings)
try:
await store.ensure_bucket()
except Exception as exc: # noqa: BLE001
logger.warning("ensure_bucket failed: %s", exc)
sessionmaker = get_sessionmaker()
logger.info(
"worker started; purge every %ds (recovery window %dd)",
settings.purge_interval_seconds,
settings.purge_after_days,
)
while True:
try:
await purge_once(sessionmaker, store)
except Exception as exc: # noqa: BLE001
logger.warning("purge cycle error: %s", exc)
await asyncio.sleep(settings.purge_interval_seconds)
if __name__ == "__main__":
asyncio.run(main())
+59
View File
@@ -0,0 +1,59 @@
"""Alembic environment — async, URL sourced from settings (DATABASE_URL)."""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import get_settings
from app.models import Base # noqa: F401 — importing registers all models
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Inject the runtime database URL (asyncpg driver) from the environment.
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def _do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(_do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,62 @@
"""auth sessions and tokens
Revision ID: 1f6e54f6406a
Revises: ec43c338e155
Create Date: 2026-06-06 10:47:06.454748
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1f6e54f6406a'
down_revision: str | None = 'ec43c338e155'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sessions',
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_sessions_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sessions'))
)
op.create_index(op.f('ix_sessions_token_hash'), 'sessions', ['token_hash'], unique=True)
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
op.create_table('user_tokens',
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('purpose', sa.Enum('email_verify', 'password_reset', name='token_purpose'), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_tokens_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_user_tokens'))
)
op.create_index(op.f('ix_user_tokens_token_hash'), 'user_tokens', ['token_hash'], unique=True)
op.create_index(op.f('ix_user_tokens_user_id'), 'user_tokens', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_tokens_user_id'), table_name='user_tokens')
op.drop_index(op.f('ix_user_tokens_token_hash'), table_name='user_tokens')
op.drop_table('user_tokens')
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
op.drop_index(op.f('ix_sessions_token_hash'), table_name='sessions')
op.drop_table('sessions')
# ### end Alembic commands ###
# Enum type created implicitly by create_table(); drop it for reversibility.
op.execute("DROP TYPE IF EXISTS token_purpose")
@@ -0,0 +1,65 @@
"""media
Revision ID: 7fc7024ef432
Revises: 1f6e54f6406a
Create Date: 2026-06-06 21:44:03.204170
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7fc7024ef432'
down_revision: str | None = '1f6e54f6406a'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('media',
sa.Column('uploader_id', sa.Uuid(), nullable=True),
sa.Column('storage_key', sa.String(length=512), nullable=False),
sa.Column('original_filename', sa.String(length=512), nullable=False),
sa.Column('content_type', sa.String(length=128), nullable=False),
sa.Column('byte_size', sa.BigInteger(), nullable=False),
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
sa.Column('title', sa.String(length=512), nullable=True),
sa.Column('person_id', sa.Uuid(), nullable=True),
sa.Column('event_id', sa.Uuid(), nullable=True),
sa.Column('source_id', sa.Uuid(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_media_event_id_events'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_media_person_id_persons'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_media_source_id_sources'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_media_tree_id_trees'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploader_id'], ['users.id'], name=op.f('fk_media_uploader_id_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_media')),
sa.UniqueConstraint('storage_key', name=op.f('uq_media_storage_key'))
)
op.create_index(op.f('ix_media_checksum_sha256'), 'media', ['checksum_sha256'], unique=False)
op.create_index(op.f('ix_media_event_id'), 'media', ['event_id'], unique=False)
op.create_index(op.f('ix_media_person_id'), 'media', ['person_id'], unique=False)
op.create_index(op.f('ix_media_source_id'), 'media', ['source_id'], unique=False)
op.create_index(op.f('ix_media_tree_id'), 'media', ['tree_id'], unique=False)
op.create_index(op.f('ix_media_uploader_id'), 'media', ['uploader_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_media_uploader_id'), table_name='media')
op.drop_index(op.f('ix_media_tree_id'), table_name='media')
op.drop_index(op.f('ix_media_source_id'), table_name='media')
op.drop_index(op.f('ix_media_person_id'), table_name='media')
op.drop_index(op.f('ix_media_event_id'), table_name='media')
op.drop_index(op.f('ix_media_checksum_sha256'), table_name='media')
op.drop_table('media')
# ### end Alembic commands ###
@@ -0,0 +1,304 @@
"""core data model
Revision ID: ec43c338e155
Revises:
Create Date: 2026-06-06 10:27:41.671787
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'ec43c338e155'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('email_verified_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('hashed_password', sa.String(length=255), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('trees',
sa.Column('owner_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('visibility', sa.Enum('public', 'unlisted', 'private', name='tree_visibility'), server_default='private', nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], name=op.f('fk_trees_owner_id_users'), ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_trees'))
)
op.create_index(op.f('ix_trees_owner_id'), 'trees', ['owner_id'], unique=False)
op.create_table('audit_entries',
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=True),
sa.Column('actor_type', sa.Enum('user', 'assistant', name='audit_actor_type'), server_default='user', nullable=False),
sa.Column('actor_user_id', sa.Uuid(), nullable=True),
sa.Column('action', sa.String(length=64), nullable=False),
sa.Column('entity_type', sa.String(length=64), nullable=False),
sa.Column('entity_id', sa.Uuid(), nullable=True),
sa.Column('before', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('after', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], name=op.f('fk_audit_entries_actor_user_id_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_audit_entries_tree_id_trees'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_entries'))
)
op.create_index(op.f('ix_audit_entries_actor_user_id'), 'audit_entries', ['actor_user_id'], unique=False)
op.create_index(op.f('ix_audit_entries_created_at'), 'audit_entries', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_entries_tree_id'), 'audit_entries', ['tree_id'], unique=False)
op.create_table('persons',
sa.Column('gender', sa.String(length=32), nullable=True),
sa.Column('is_living', sa.Boolean(), nullable=True),
sa.Column('privacy', sa.Enum('inherit', 'private', 'public', name='person_privacy'), server_default='inherit', nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_persons_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_persons'))
)
op.create_index(op.f('ix_persons_tree_id'), 'persons', ['tree_id'], unique=False)
op.create_table('places',
sa.Column('name', sa.String(length=512), nullable=False),
sa.Column('parent_id', sa.Uuid(), nullable=True),
sa.Column('place_type', sa.String(length=64), nullable=True),
sa.Column('latitude', sa.Float(), nullable=True),
sa.Column('longitude', sa.Float(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['parent_id'], ['places.id'], name=op.f('fk_places_parent_id_places'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_places_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_places'))
)
op.create_index(op.f('ix_places_parent_id'), 'places', ['parent_id'], unique=False)
op.create_index(op.f('ix_places_tree_id'), 'places', ['tree_id'], unique=False)
op.create_table('sources',
sa.Column('title', sa.String(length=512), nullable=False),
sa.Column('author', sa.String(length=255), nullable=True),
sa.Column('source_type', sa.String(length=64), nullable=True),
sa.Column('repository', sa.String(length=255), nullable=True),
sa.Column('url', sa.String(length=1024), nullable=True),
sa.Column('citation_text', sa.Text(), nullable=True),
sa.Column('publication_info', sa.Text(), nullable=True),
sa.Column('quality_note', sa.String(length=255), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_sources_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sources'))
)
op.create_index(op.f('ix_sources_tree_id'), 'sources', ['tree_id'], unique=False)
op.create_table('tree_memberships',
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('role', sa.Enum('owner', 'editor', 'viewer', name='membership_role'), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_tree_memberships_tree_id_trees'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_tree_memberships_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tree_memberships')),
sa.UniqueConstraint('tree_id', 'user_id', name='uq_tree_memberships_tree_user')
)
op.create_index(op.f('ix_tree_memberships_tree_id'), 'tree_memberships', ['tree_id'], unique=False)
op.create_index(op.f('ix_tree_memberships_user_id'), 'tree_memberships', ['user_id'], unique=False)
op.create_table('names',
sa.Column('person_id', sa.Uuid(), nullable=False),
sa.Column('name_type', sa.String(length=32), server_default='birth', nullable=False),
sa.Column('given', sa.String(length=255), nullable=True),
sa.Column('surname', sa.String(length=255), nullable=True),
sa.Column('prefix', sa.String(length=64), nullable=True),
sa.Column('suffix', sa.String(length=64), nullable=True),
sa.Column('nickname', sa.String(length=128), nullable=True),
sa.Column('display_name', sa.String(length=512), nullable=True),
sa.Column('is_primary', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_names_person_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_names_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_names'))
)
op.create_index(op.f('ix_names_person_id'), 'names', ['person_id'], unique=False)
op.create_index(op.f('ix_names_tree_id'), 'names', ['tree_id'], unique=False)
op.create_table('place_names',
sa.Column('place_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=512), nullable=False),
sa.Column('valid_from', sa.Date(), nullable=True),
sa.Column('valid_to', sa.Date(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_place_names_place_id_places'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_place_names_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_place_names'))
)
op.create_index(op.f('ix_place_names_place_id'), 'place_names', ['place_id'], unique=False)
op.create_index(op.f('ix_place_names_tree_id'), 'place_names', ['tree_id'], unique=False)
op.create_table('relationships',
sa.Column('type', sa.Enum('parent_child', 'partnership', 'sibling', name='relationship_type'), nullable=False),
sa.Column('person_from_id', sa.Uuid(), nullable=False),
sa.Column('person_to_id', sa.Uuid(), nullable=False),
sa.Column('qualifier', sa.Enum('biological', 'adoptive', 'step', 'foster', 'donor', 'guardian', name='parent_child_qualifier'), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint('person_from_id <> person_to_id', name=op.f('ck_relationships_different_persons')),
sa.ForeignKeyConstraint(['person_from_id'], ['persons.id'], name=op.f('fk_relationships_person_from_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['person_to_id'], ['persons.id'], name=op.f('fk_relationships_person_to_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_relationships_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_relationships'))
)
op.create_index(op.f('ix_relationships_person_from_id'), 'relationships', ['person_from_id'], unique=False)
op.create_index(op.f('ix_relationships_person_to_id'), 'relationships', ['person_to_id'], unique=False)
op.create_index(op.f('ix_relationships_tree_id'), 'relationships', ['tree_id'], unique=False)
op.create_table('events',
sa.Column('event_type', sa.String(length=64), nullable=False),
sa.Column('person_id', sa.Uuid(), nullable=True),
sa.Column('relationship_id', sa.Uuid(), nullable=True),
sa.Column('place_id', sa.Uuid(), nullable=True),
sa.Column('date_value', sa.String(length=255), nullable=True),
sa.Column('date_start', sa.Date(), nullable=True),
sa.Column('date_end', sa.Date(), nullable=True),
sa.Column('date_precision', sa.String(length=32), nullable=True),
sa.Column('calendar', sa.String(length=32), server_default='gregorian', nullable=False),
sa.Column('detail', sa.String(length=512), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint('(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)', name=op.f('ck_events_subject_person_xor_relationship')),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_events_person_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_events_place_id_places'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_events_relationship_id_relationships'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_events_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_events'))
)
op.create_index(op.f('ix_events_event_type'), 'events', ['event_type'], unique=False)
op.create_index(op.f('ix_events_person_id'), 'events', ['person_id'], unique=False)
op.create_index(op.f('ix_events_place_id'), 'events', ['place_id'], unique=False)
op.create_index(op.f('ix_events_relationship_id'), 'events', ['relationship_id'], unique=False)
op.create_index(op.f('ix_events_tree_id'), 'events', ['tree_id'], unique=False)
op.create_table('citations',
sa.Column('source_id', sa.Uuid(), nullable=False),
sa.Column('person_id', sa.Uuid(), nullable=True),
sa.Column('event_id', sa.Uuid(), nullable=True),
sa.Column('name_id', sa.Uuid(), nullable=True),
sa.Column('relationship_id', sa.Uuid(), nullable=True),
sa.Column('page', sa.String(length=255), nullable=True),
sa.Column('detail', sa.Text(), nullable=True),
sa.Column('confidence', sa.Enum('high', 'medium', 'low', name='citation_confidence'), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint('(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int + (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1', name=op.f('ck_citations_exactly_one_target')),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_citations_event_id_events'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['name_id'], ['names.id'], name=op.f('fk_citations_name_id_names'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_citations_person_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_citations_relationship_id_relationships'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_citations_source_id_sources'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_citations_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_citations'))
)
op.create_index(op.f('ix_citations_event_id'), 'citations', ['event_id'], unique=False)
op.create_index(op.f('ix_citations_name_id'), 'citations', ['name_id'], unique=False)
op.create_index(op.f('ix_citations_person_id'), 'citations', ['person_id'], unique=False)
op.create_index(op.f('ix_citations_relationship_id'), 'citations', ['relationship_id'], unique=False)
op.create_index(op.f('ix_citations_source_id'), 'citations', ['source_id'], unique=False)
op.create_index(op.f('ix_citations_tree_id'), 'citations', ['tree_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_citations_tree_id'), table_name='citations')
op.drop_index(op.f('ix_citations_source_id'), table_name='citations')
op.drop_index(op.f('ix_citations_relationship_id'), table_name='citations')
op.drop_index(op.f('ix_citations_person_id'), table_name='citations')
op.drop_index(op.f('ix_citations_name_id'), table_name='citations')
op.drop_index(op.f('ix_citations_event_id'), table_name='citations')
op.drop_table('citations')
op.drop_index(op.f('ix_events_tree_id'), table_name='events')
op.drop_index(op.f('ix_events_relationship_id'), table_name='events')
op.drop_index(op.f('ix_events_place_id'), table_name='events')
op.drop_index(op.f('ix_events_person_id'), table_name='events')
op.drop_index(op.f('ix_events_event_type'), table_name='events')
op.drop_table('events')
op.drop_index(op.f('ix_relationships_tree_id'), table_name='relationships')
op.drop_index(op.f('ix_relationships_person_to_id'), table_name='relationships')
op.drop_index(op.f('ix_relationships_person_from_id'), table_name='relationships')
op.drop_table('relationships')
op.drop_index(op.f('ix_place_names_tree_id'), table_name='place_names')
op.drop_index(op.f('ix_place_names_place_id'), table_name='place_names')
op.drop_table('place_names')
op.drop_index(op.f('ix_names_tree_id'), table_name='names')
op.drop_index(op.f('ix_names_person_id'), table_name='names')
op.drop_table('names')
op.drop_index(op.f('ix_tree_memberships_user_id'), table_name='tree_memberships')
op.drop_index(op.f('ix_tree_memberships_tree_id'), table_name='tree_memberships')
op.drop_table('tree_memberships')
op.drop_index(op.f('ix_sources_tree_id'), table_name='sources')
op.drop_table('sources')
op.drop_index(op.f('ix_places_tree_id'), table_name='places')
op.drop_index(op.f('ix_places_parent_id'), table_name='places')
op.drop_table('places')
op.drop_index(op.f('ix_persons_tree_id'), table_name='persons')
op.drop_table('persons')
op.drop_index(op.f('ix_audit_entries_tree_id'), table_name='audit_entries')
op.drop_index(op.f('ix_audit_entries_created_at'), table_name='audit_entries')
op.drop_index(op.f('ix_audit_entries_actor_user_id'), table_name='audit_entries')
op.drop_table('audit_entries')
op.drop_index(op.f('ix_trees_owner_id'), table_name='trees')
op.drop_table('trees')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
# Enum types are created implicitly by create_table() but not dropped by
# drop_table(); drop them explicitly so downgrade is fully reversible.
for enum_name in (
"tree_visibility",
"membership_role",
"person_privacy",
"relationship_type",
"parent_child_qualifier",
"citation_confidence",
"audit_actor_type",
):
op.execute(f"DROP TYPE IF EXISTS {enum_name}")
+47
View File
@@ -0,0 +1,47 @@
[project]
name = "provenance-backend"
version = "0.0.0"
description = "Provenance backend — FastAPI service for family + land provenance."
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"pydantic>=2.9",
"pydantic-settings>=2.5",
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.30",
"alembic>=1.14",
"argon2-cffi>=23.1",
"boto3>=1.35",
"python-multipart>=0.0.12",
]
[dependency-groups]
dev = [
"ruff>=0.8",
"pytest>=8.3",
"pytest-asyncio>=0.24",
"httpx>=0.27",
]
# This is an application, not a library: install dependencies but do not build/
# install the project itself. Code runs from source via `uv run`.
[tool.uv]
package = false
[tool.ruff]
line-length = 100
target-version = "py313"
# Alembic writes the migration files; don't hold generated code to our style.
extend-exclude = ["migrations/versions"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.flake8-bugbear]
# FastAPI uses these as call-expressions in argument defaults by design.
extend-immutable-calls = ["fastapi.File", "fastapi.Form", "fastapi.Depends", "fastapi.Query", "fastapi.Header"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["."]
+113
View File
@@ -0,0 +1,113 @@
"""Test fixtures.
DB-backed tests require ``TEST_DATABASE_URL`` (an async URL to a *disposable*
Postgres); without it they skip, so the no-DB unit suite still runs anywhere.
The schema is built from the models via ``create_all`` and dropped per test for
isolation. A capturing mailer replaces the real one so email flows are testable.
"""
import os
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — register all models on Base.metadata
from app.api.deps import get_mailer, get_objectstore
from app.core.db import get_session
from app.integrations.mailer.base import Mailer
from app.integrations.objectstore.base import ObjectStore
from app.main import app
from app.models import Base
TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
class CapturingMailer(Mailer):
def __init__(self) -> None:
self.verifications: list[tuple[str, str]] = []
self.resets: list[tuple[str, str]] = []
async def send_email_verification(self, *, to: str, link: str) -> None:
self.verifications.append((to, link))
async def send_password_reset(self, *, to: str, link: str) -> None:
self.resets.append((to, link))
class FakeObjectStore(ObjectStore):
def __init__(self) -> None:
self.objects: dict[str, tuple[bytes, str]] = {}
async def ensure_bucket(self) -> None:
pass
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
self.objects[key] = (data, content_type)
async def get_object(self, *, key: str) -> bytes:
return self.objects[key][0]
async def presigned_get_url(self, *, key: str) -> str:
return f"https://objects.test/{key}"
async def delete_object(self, *, key: str) -> None:
self.objects.pop(key, None)
_mailer = CapturingMailer()
_store = FakeObjectStore()
@pytest.fixture
def mailer() -> CapturingMailer:
return _mailer
@pytest_asyncio.fixture
async def client():
if not TEST_DATABASE_URL:
pytest.skip("TEST_DATABASE_URL not set")
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def _override_session():
async with sessionmaker() as session:
yield session
_mailer.verifications.clear()
_mailer.resets.clear()
_store.objects.clear()
app.dependency_overrides[get_session] = _override_session
app.dependency_overrides[get_mailer] = lambda: _mailer
app.dependency_overrides[get_objectstore] = lambda: _store
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
app.dependency_overrides.clear()
await engine.dispose()
def token_from_link(link: str) -> str:
return link.split("token=", 1)[1]
async def register(client, email: str, password: str = "password123") -> str:
"""Register a user and return their bearer session token."""
resp = await client.post(
"/api/v1/auth/register", json={"email": email, "password": password}
)
assert resp.status_code == 201, resp.text
return resp.json()["token"]
def auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
+106
View File
@@ -0,0 +1,106 @@
"""Auth flows: registration, login, email verification, password reset, logout."""
from tests.conftest import auth, register, token_from_link
async def test_register_issues_session_and_verification_email(client, mailer):
resp = await client.post(
"/api/v1/auth/register",
json={"email": "new@example.com", "password": "password123", "display_name": "New"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["token"]
assert body["user"]["email"] == "new@example.com"
assert body["user"]["email_verified_at"] is None
# A verification email was "sent".
assert len(mailer.verifications) == 1
assert mailer.verifications[0][0] == "new@example.com"
async def test_duplicate_email_conflicts(client):
await register(client, "dupe@example.com")
resp = await client.post(
"/api/v1/auth/register", json={"email": "dupe@example.com", "password": "password123"}
)
assert resp.status_code == 409
async def test_login_wrong_password_rejected(client):
await register(client, "user@example.com", password="password123")
resp = await client.post(
"/api/v1/auth/login", json={"email": "user@example.com", "password": "wrong-password"}
)
assert resp.status_code == 401
async def test_login_succeeds_and_me_returns_user(client):
await register(client, "user2@example.com", password="password123")
resp = await client.post(
"/api/v1/auth/login", json={"email": "user2@example.com", "password": "password123"}
)
assert resp.status_code == 200
token = resp.json()["token"]
resp = await client.get("/api/v1/users/me", headers=auth(token))
assert resp.status_code == 200
assert resp.json()["email"] == "user2@example.com"
async def test_email_verification(client, mailer):
await register(client, "verify@example.com")
token = token_from_link(mailer.verifications[0][1])
resp = await client.post("/api/v1/auth/verify-email", json={"token": token})
assert resp.status_code == 204
# Logging in and checking /me shows the address is now verified.
login = await client.post(
"/api/v1/auth/login", json={"email": "verify@example.com", "password": "password123"}
)
me = await client.get("/api/v1/users/me", headers=auth(login.json()["token"]))
assert me.json()["email_verified_at"] is not None
async def test_password_reset_flow_revokes_old_sessions(client, mailer):
old_token = await register(client, "reset@example.com", password="password123")
resp = await client.post(
"/api/v1/auth/request-password-reset", json={"email": "reset@example.com"}
)
assert resp.status_code == 202
reset_token = token_from_link(mailer.resets[0][1])
resp = await client.post(
"/api/v1/auth/reset-password",
json={"token": reset_token, "new_password": "new-password456"},
)
assert resp.status_code == 204
# Old session is revoked.
assert (await client.get("/api/v1/users/me", headers=auth(old_token))).status_code == 401
# Old password no longer works; new one does.
assert (
await client.post(
"/api/v1/auth/login", json={"email": "reset@example.com", "password": "password123"}
)
).status_code == 401
assert (
await client.post(
"/api/v1/auth/login",
json={"email": "reset@example.com", "password": "new-password456"},
)
).status_code == 200
async def test_request_password_reset_unknown_email_still_accepted(client, mailer):
resp = await client.post(
"/api/v1/auth/request-password-reset", json={"email": "nobody@example.com"}
)
assert resp.status_code == 202
assert len(mailer.resets) == 0 # no email sent, but no enumeration either
async def test_logout_revokes_session(client):
token = await register(client, "logout@example.com")
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
+73
View File
@@ -0,0 +1,73 @@
"""End-to-end coverage of the core data model through the API: tenancy, the
privacy seam, and real session auth."""
from tests.conftest import auth, register
async def test_tree_and_person_flow(client):
token = await register(client, "keeper@example.com")
resp = await client.post(
"/api/v1/trees",
json={"name": "Smith Family", "visibility": "private"},
headers=auth(token),
)
assert resp.status_code == 201, resp.text
tree = resp.json()
assert tree["visibility"] == "private"
tree_id = tree["id"]
resp = await client.get("/api/v1/trees", headers=auth(token))
assert resp.status_code == 200
assert len(resp.json()) == 1
resp = await client.post(
f"/api/v1/trees/{tree_id}/persons",
json={"given": "John", "surname": "Smith"},
headers=auth(token),
)
assert resp.status_code == 201, resp.text
person = resp.json()
assert person["primary_name"] == "John Smith"
assert person["tree_id"] == tree_id
resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers=auth(token))
assert resp.status_code == 200
assert len(resp.json()) == 1
async def test_private_tree_isolated_from_other_users(client):
owner = await register(client, "owner@example.com")
other = await register(client, "stranger@example.com")
resp = await client.post(
"/api/v1/trees", json={"name": "Private", "visibility": "private"}, headers=auth(owner)
)
tree_id = resp.json()["id"]
resp = await client.get(f"/api/v1/trees/{tree_id}", headers=auth(other))
assert resp.status_code == 403
resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers=auth(other))
assert resp.status_code == 403
async def test_public_tree_viewable_but_not_editable_by_non_member(client):
owner = await register(client, "owner2@example.com")
viewer = await register(client, "viewer2@example.com")
resp = await client.post(
"/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=auth(owner)
)
tree_id = resp.json()["id"]
resp = await client.get(f"/api/v1/trees/{tree_id}", headers=auth(viewer))
assert resp.status_code == 200
resp = await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Nope"}, headers=auth(viewer)
)
assert resp.status_code == 403
async def test_auth_required_without_token(client):
resp = await client.get("/api/v1/trees")
assert resp.status_code == 401
+116
View File
@@ -0,0 +1,116 @@
"""Events and relationships through the API."""
from tests.conftest import auth, register
async def _setup_tree_with_two_people(client, email: str):
token = await register(client, email)
h = auth(token)
tree_id = (
await client.post("/api/v1/trees", json={"name": "Graph"}, headers=h)
).json()["id"]
parent = (
await client.post(
f"/api/v1/trees/{tree_id}/persons",
json={"given": "Anna", "surname": "Vogel"},
headers=h,
)
).json()["id"]
child = (
await client.post(
f"/api/v1/trees/{tree_id}/persons",
json={"given": "Beth", "surname": "Vogel"},
headers=h,
)
).json()["id"]
return h, tree_id, parent, child
async def test_event_create_list_delete(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "ev1@example.com")
resp = await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": parent, "date_value": "ABT 1850"},
headers=h,
)
assert resp.status_code == 201, resp.text
event_id = resp.json()["id"]
listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h)
assert listed.status_code == 200
assert len(listed.json()) == 1
assert listed.json()[0]["event_type"] == "birth"
resp = await client.delete(f"/api/v1/trees/{tree_id}/events/{event_id}", headers=h)
assert resp.status_code == 204
listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h)
assert len(listed.json()) == 0
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(
f"/api/v1/trees/{tree_id}/events", json={"event_type": "birth"}, headers=h
)
assert resp.status_code == 409
async def test_relationship_create_and_list(client):
h, tree_id, parent, child = await _setup_tree_with_two_people(client, "rel1@example.com")
resp = await client.post(
f"/api/v1/trees/{tree_id}/relationships",
json={
"type": "parent_child",
"person_from_id": parent,
"person_to_id": child,
"qualifier": "biological",
},
headers=h,
)
assert resp.status_code == 201, resp.text
for pid in (parent, child):
listed = await client.get(
f"/api/v1/trees/{tree_id}/persons/{pid}/relationships", headers=h
)
assert listed.status_code == 200
assert len(listed.json()) == 1
assert listed.json()[0]["qualifier"] == "biological"
async def test_relationship_validation(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel2@example.com")
# Same person on both ends.
resp = await client.post(
f"/api/v1/trees/{tree_id}/relationships",
json={"type": "sibling", "person_from_id": parent, "person_to_id": parent},
headers=h,
)
assert resp.status_code == 409
# Qualifier on a non-parent_child edge.
h2, t2, p_a, p_b = await _setup_tree_with_two_people(client, "rel3@example.com")
resp = await client.post(
f"/api/v1/trees/{t2}/relationships",
json={
"type": "partnership",
"person_from_id": p_a,
"person_to_id": p_b,
"qualifier": "biological",
},
headers=h2,
)
assert resp.status_code == 409
async def test_non_member_cannot_write_graph(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel4@example.com")
other = auth(await register(client, "intruder@example.com"))
resp = await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": parent},
headers=other,
)
assert resp.status_code == 403
+17
View File
@@ -0,0 +1,17 @@
"""Liveness probe test. Readiness is covered by an integration test once a
test database fixture exists (it requires a live Postgres)."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health_liveness():
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["service"] == "Provenance"
assert "version" in body
+50
View File
@@ -0,0 +1,50 @@
"""Media upload/list/delete through the API (object store faked in conftest)."""
from tests.conftest import auth, register
async def _tree(client, email):
h = auth(await register(client, email))
tree_id = (await client.post("/api/v1/trees", json={"name": "M"}, headers=h)).json()["id"]
return h, tree_id
async def test_media_upload_list_delete(client):
h, tree_id = await _tree(client, "media1@example.com")
resp = await client.post(
f"/api/v1/trees/{tree_id}/media",
files={"file": ("scan.txt", b"hello world", "text/plain")},
data={"title": "A scan"},
headers=h,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["original_filename"] == "scan.txt"
assert body["byte_size"] == 11
assert body["url"] == f"/api/v1/trees/{tree_id}/media/{body['id']}/content"
media_id = body["id"]
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
assert listed.status_code == 200
assert len(listed.json()) == 1
# The content endpoint streams the bytes back.
content = await client.get(f"/api/v1/trees/{tree_id}/media/{media_id}/content", headers=h)
assert content.status_code == 200
assert content.content == b"hello world"
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
async def test_non_member_cannot_upload(client):
h, tree_id = await _tree(client, "media2@example.com")
other = auth(await register(client, "media-intruder@example.com"))
resp = await client.post(
f"/api/v1/trees/{tree_id}/media",
files={"file": ("x.txt", b"x", "text/plain")},
headers=other,
)
assert resp.status_code == 403
+54
View File
@@ -0,0 +1,54 @@
"""Soft-delete + recovery for trees and people."""
from tests.conftest import auth, register
async def test_tree_delete_and_restore(client):
h = auth(await register(client, "rec1@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
# Delete -> gone from active lists, present in the recovery list.
assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0
# A soft-deleted tree is no longer visible (404 to the would-be viewer).
gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h)
assert gone.status_code == 404
deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json()
assert len(deleted) == 1 and deleted[0]["id"] == tree_id
# Restore -> back in active lists.
assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1
assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200
async def test_only_owner_can_delete_tree(client):
owner = auth(await register(client, "rec-owner@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
other = auth(await register(client, "rec-other@example.com"))
blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other)
assert blocked.status_code in (403, 404)
async def test_person_delete_and_restore(client):
h = auth(await register(client, "rec2@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
person_id = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h
)
).json()["id"]
assert (
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
).status_code == 204
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)
).json()
assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada"
assert (
await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h)
).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1
+96
View File
@@ -0,0 +1,96 @@
"""Sources and citations (the provenance spine)."""
import uuid
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tree_id = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
person = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Cy"}, headers=h
)
).json()["id"]
event = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": person},
headers=h,
)
).json()["id"]
return h, tree_id, person, event
async def test_source_and_citation_flow(client):
h, tree_id, person, event = await _setup(client, "src1@example.com")
src = await client.post(
f"/api/v1/trees/{tree_id}/sources",
json={"title": "1880 Census", "repository": "NARA"},
headers=h,
)
assert src.status_code == 201, src.text
source_id = src.json()["id"]
assert len((await client.get(f"/api/v1/trees/{tree_id}/sources", headers=h)).json()) == 1
# Cite the source on the birth event.
cite = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": source_id, "event_id": event, "page": "p. 4"},
headers=h,
)
assert cite.status_code == 201, cite.text
citation_id = cite.json()["id"]
citations = (await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()
assert len(citations) == 1
assert citations[0]["event_id"] == event
assert citations[0]["source_id"] == source_id
resp = await client.delete(f"/api/v1/trees/{tree_id}/citations/{citation_id}", headers=h)
assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()) == 0
async def test_citation_needs_exactly_one_target(client):
h, tree_id, person, event = await _setup(client, "src2@example.com")
source_id = (
await client.post(
f"/api/v1/trees/{tree_id}/sources", json={"title": "X"}, headers=h
)
).json()["id"]
# No target.
r = await client.post(
f"/api/v1/trees/{tree_id}/citations", json={"source_id": source_id}, headers=h
)
assert r.status_code == 409
# Two targets.
r = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": source_id, "person_id": person, "event_id": event},
headers=h,
)
assert r.status_code == 409
async def test_citation_unknown_source_404(client):
h, tree_id, person, _ = await _setup(client, "src3@example.com")
r = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": str(uuid.uuid4()), "person_id": person},
headers=h,
)
assert r.status_code == 404
async def test_non_member_cannot_create_source(client):
h, tree_id, _, _ = await _setup(client, "src4@example.com")
other = auth(await register(client, "src-intruder@example.com"))
r = await client.post(
f"/api/v1/trees/{tree_id}/sources", json={"title": "nope"}, headers=other
)
assert r.status_code == 403
+1006
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
# Provenance configuration — copy to `.env` and fill in. Never commit `.env`.
# Everything is twelve-factor; no endpoints or secrets live in code.
# --- Core ---
APP_ENV=development
# --- Images (pulled from git.jpaul.io; CI pushes to the LAN registry) ---
# test-main = current main build; or pin a semver / test-sha-<sha> for rollback.
IMAGE_TAG=test-main
# --- Database (Postgres) ---
POSTGRES_USER=provenance
POSTGRES_PASSWORD=change-me
POSTGRES_DB=provenance
# Backend connection string (async driver). Host 'postgres' = compose service.
DATABASE_URL=postgresql+asyncpg://provenance:change-me@postgres:5432/provenance
# --- Object storage (S3-compatible / MinIO) ---
MINIO_ROOT_USER=provenance
MINIO_ROOT_PASSWORD=change-me-too
S3_ENDPOINT_URL=http://minio:9000
S3_BUCKET=provenance
S3_ACCESS_KEY=provenance
S3_SECRET_KEY=change-me-too
S3_REGION=us-east-1
# --- Edge (Caddy) ---
# Local: ':80' (http://localhost). Production: 'provenance.example.com' for auto-HTTPS.
# Behind a Cloudflare Tunnel, keep ':80' — Cloudflare terminates TLS and the
# tunnel forwards plain HTTP to caddy:80.
PROVENANCE_SITE_ADDRESS=:80
# --- Deploy-host services (optional, selected via COMPOSE_PROFILES) ---
# 'tunnel' -> cloudflared connector (needs CLOUDFLARE_TUNNEL_TOKEN; public hostname -> http://caddy:80)
# Auto-deploy is handled by the host's global Watchtower (watches the
# watchtower-enabled backend/frontend labels) — no profile needed here.
CLOUDFLARE_TUNNEL_TOKEN=
COMPOSE_PROFILES=
# --- Auth / sessions ---
SESSION_TTL_DAYS=30
TOKEN_TTL_HOURS=24
# Set false for local http; true (default) behind TLS.
COOKIE_SECURE=false
# Base URL used to build links in outbound email.
APP_BASE_URL=http://localhost
# Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below.
MAILER=console
# --- Email (SMTP) — wired in a later phase ---
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM=
# --- Model providers — wired in Phase 4 (AI assistant). BYO key. ---
# ANTHROPIC_API_KEY=
# OPENAI_API_KEY=
# XAI_API_KEY=
+22
View File
@@ -0,0 +1,22 @@
# Provenance edge. Site address is env-driven: ':80' for local http://localhost,
# a domain in production for automatic HTTPS. Behind a Cloudflare Tunnel you can
# keep this on plain HTTP and let the tunnel terminate TLS.
{$PROVENANCE_SITE_ADDRESS::80} {
encode gzip
# Versioned API surface (FastAPI). The assistant mounts under /assistant later.
handle /api/* {
reverse_proxy backend:8000
}
# Liveness/readiness probes, proxied for external monitoring.
handle /health* {
reverse_proxy backend:8000
}
# Everything else -> the Next.js frontend.
handle {
reverse_proxy frontend:3000
}
}
+23
View File
@@ -0,0 +1,23 @@
name: provenance
# Dev override: build the backend/frontend images locally instead of pulling
# them from the registry. Layer it on top of the base compose:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
#
# Use this before CI has published images, or to test local changes.
services:
backend:
build:
context: ../backend
dockerfile: Dockerfile
worker:
build:
context: ../backend
dockerfile: Dockerfile
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
+150
View File
@@ -0,0 +1,150 @@
name: provenance
# One stack stands up the whole system. Configuration is entirely env-driven
# (see .env.example). Run from this directory: `docker compose up -d`.
#
# backend/frontend are PULLED from the public registry (git.jpaul.io); CI pushes
# them to the LAN endpoint (192.168.0.2:1234). For local building instead of
# pulling, layer the dev override:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
services:
postgres:
# pgvector image = Postgres + pgvector; pg_trgm ships in contrib.
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: ${POSTGRES_USER:-provenance}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-provenance}
POSTGRES_DB: ${POSTGRES_DB:-provenance}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-provenance} -d ${POSTGRES_DB:-provenance}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-provenance}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-too}
volumes:
- miniodata:/data
healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
backend:
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
labels:
com.centurylinklabs.watchtower.enable: "true"
environment:
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
S3_BUCKET: ${S3_BUCKET:-provenance}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
S3_REGION: ${S3_REGION:-us-east-1}
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- >-
python -c "import urllib.request,sys;
sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
# Background worker — same image as the backend, run in worker mode.
# First job: the scheduled soft-delete purge (and media object cleanup).
worker:
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
command: ["uv", "run", "--no-dev", "python", "-m", "app.worker"]
labels:
com.centurylinklabs.watchtower.enable: "true"
environment:
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
S3_BUCKET: ${S3_BUCKET:-provenance}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
S3_REGION: ${S3_REGION:-us-east-1}
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
frontend:
image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main}
labels:
com.centurylinklabs.watchtower.enable: "true"
environment:
NODE_ENV: production
depends_on:
- backend
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
environment:
# Local default ':80' -> http://localhost. Set to a domain in production
# for automatic HTTPS (or run plain HTTP behind a Cloudflare Tunnel).
PROVENANCE_SITE_ADDRESS: ${PROVENANCE_SITE_ADDRESS:-:80}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddydata:/data
- caddyconfig:/config
depends_on:
- backend
- frontend
restart: unless-stopped
# Cloudflare Tunnel connector. The tunnel/ingress is configured in the
# Cloudflare dashboard; this container just connects. One public hostname
# (e.g. provenance.paul.farm) -> http://caddy:80 is enough, because Caddy
# does the internal path routing (/ -> frontend, /api + /health -> backend).
#
# Opt-in via the "tunnel" profile so local dev doesn't start it. On the lab
# host set COMPOSE_PROFILES=tunnel so `docker compose up -d` includes it.
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-}
depends_on:
- caddy
profiles:
- tunnel
# Auto-deploy is handled by the host's global Watchtower (a single
# nickfedor/watchtower instance watches every container labelled
# `com.centurylinklabs.watchtower.enable=true` across all stacks). The backend
# and frontend carry that label above, so a new :test-main image is pulled and
# the container recreated automatically — no per-stack Watchtower needed.
volumes:
pgdata:
miniodata:
caddydata:
caddyconfig:
+18 -3
View File
@@ -79,6 +79,7 @@ Async throughout (FastAPI + async SQLAlchemy). Anything that can be slow or can
- **Authoring UI** is client-side, talking to the REST API. A generated TypeScript client keeps it in sync with the OpenAPI contract. - **Authoring UI** is client-side, talking to the REST API. A generated TypeScript client keeps it in sync with the OpenAPI contract.
- **Mobile-first.** Layouts are responsive and touch-first; no separate mobile codebase. Feature parity with desktop is a requirement, not a nice-to-have. - **Mobile-first.** Layouts are responsive and touch-first; no separate mobile codebase. Feature parity with desktop is a requirement, not a nice-to-have.
- **Design system:** Tailwind + shadcn/ui for a consistent, accessible (WCAG 2.2 AA target) component layer. - **Design system:** Tailwind + shadcn/ui for a consistent, accessible (WCAG 2.2 AA target) component layer.
- *Status:* the scaffold has landed — Next.js (App Router) + Tailwind + shadcn-style primitives, a typed client generated from the backend OpenAPI spec (`openapi-typescript` + `openapi-fetch`), and auth + tree/person views. Auth rides the same-origin HttpOnly session cookie (Caddy proxies `/api/*` to the backend). Built as a standalone container; Caddy routes `/` to it.
## 5. Data model ## 5. Data model
@@ -93,7 +94,7 @@ Core entities and the important relationships. (Illustrative, not final DDL.)
- **Person** — belongs to a Tree. Has many **Name** records (with parts: given, surname, prefix/suffix, and a type such as birth/married/alias) to support variants and changes over time. Carries living/deceased status. - **Person** — belongs to a Tree. Has many **Name** records (with parts: given, surname, prefix/suffix, and a type such as birth/married/alias) to support variants and changes over time. Carries living/deceased status.
- **Relationship** — typed edge between two Persons within a Tree: parentchild (with a qualifier: biological, adoptive, step, foster, donor, guardian), partnership/marriage (with its own events), sibling (often derived). Modeling parentage as qualified edges — rather than assuming a two-biological-parent nuclear family — is what makes adoption, donor conception, and blended families first-class rather than awkward. - **Relationship** — typed edge between two Persons within a Tree: parentchild (with a qualifier: biological, adoptive, step, foster, donor, guardian), partnership/marriage (with its own events), sibling (often derived). Modeling parentage as qualified edges — rather than assuming a two-biological-parent nuclear family — is what makes adoption, donor conception, and blended families first-class rather than awkward.
- **Event** — typed (birth, death, marriage, residence, immigration, etc.), with a date (supporting ranges, approximations, and non-Gregorian calendars), an optional Place, and attachable to a Person or a partnership. - **Event** — typed (birth, death, marriage, residence, immigration, etc.), with a date (supporting ranges, approximations, and non-Gregorian calendars), an optional Place, and attachable to a Person or a partnership.
- **Place** — a tenant-shared gazetteer entity: hierarchical (place within place), with **historical name variants and date ranges** so a record entered as "Königsberg, 1900" sorts and displays correctly against "Kaliningrad." Optional coordinates. - **Place** — a tenant-shared gazetteer entity: hierarchical (place within place), with **historical name variants and date ranges** so a record entered as "Königsberg, 1900" sorts and displays correctly against "Kaliningrad." Optional coordinates. *(Phase 0 scopes Place to a Tree via `tree_id` for absolute tenant isolation; a deployment-wide shared gazetteer is a deliberate later refinement. Variants live in a `PlaceName` child table.)*
### Sources (first-class) ### Sources (first-class)
- **Source** — a reusable record of an origin: title, repository, type, optional URL, free citation text, optional quality grade. One Source backs many facts. - **Source** — a reusable record of an origin: title, repository, type, optional URL, free citation text, optional quality grade. One Source backs many facts.
@@ -147,6 +148,7 @@ Three parts, deliberately separated:
- `AuthProvider` interface with implementations for **local** (password + email verification/reset), **OIDC** (validated against Authentik; expected to work with Keycloak, Auth0, etc.), and **social** (Google, Apple, Facebook). - `AuthProvider` interface with implementations for **local** (password + email verification/reset), **OIDC** (validated against Authentik; expected to work with Keycloak, Auth0, etc.), and **social** (Google, Apple, Facebook).
- Operators enable any subset via config. This deployment will use Authentik (`auth.jpaul.io`) plus selected social providers; a bare self-hoster can run local-only. - Operators enable any subset via config. This deployment will use Authentik (`auth.jpaul.io`) plus selected social providers; a bare self-hoster can run local-only.
- Sessions are backend-issued; the assistant principal is minted per-session and scoped to the acting user. - Sessions are backend-issued; the assistant principal is minted per-session and scoped to the acting user.
- *Status:* **local auth has landed** — Argon2id password hashing, opaque backend-issued sessions (only the token hash is stored; presented as a Bearer token or HttpOnly cookie), and email verification + password reset via the `Mailer` interface (console in dev, SMTP for operators). OIDC and social providers are Phase 5. Every write records an attributable actor in the audit log.
## 10. Search ## 10. Search
@@ -168,13 +170,26 @@ Jobs are idempotent and retryable; an external failure degrades gracefully rathe
## 12. Deployment & CI/CD ## 12. Deployment & CI/CD
- **Images** are built by **Gitea Actions** on `git.jpaul.io` and pushed to the **Gitea container registry**. - **Images** are built by **Gitea Actions** (`runs-on: docker`) and pushed to the **Gitea container registry**, one package per component (`provenance-backend`, `provenance-frontend`) linked to the repo.
- Servers **pull** new images to deploy — no build on the host. - **Split push/pull endpoints** (mirrors the drawbar setup): CI **pushes** to the LAN registry endpoint `192.168.0.2:1234` over plain HTTP (buildx configured `insecure`/`http`) to bypass the Cloudflare request-body limit; servers **pull** from the public `git.jpaul.io` FQDN (TLS via Cloudflare). Same Gitea registry, two front doors. Auth uses the `REGISTRY_TOKEN` Actions secret.
- Tag scheme: `test-main` (current main), `test-sha-<long>` (rollback pins), the component version, and `latest` on `v*` tags.
- Servers **pull** new images to deploy — no build on the host. The deploy compose references `git.jpaul.io/justin/provenance-{backend,frontend}:${IMAGE_TAG:-test-main}`; `docker-compose.dev.yml` is a local-build override.
- **Caddy** terminates TLS and reverse-proxies frontend + backend. **Cloudflare Tunnel** is the preferred ingress (no open inbound ports) but is never required; a plain Caddy-on-a-public-host deployment is equally supported. - **Caddy** terminates TLS and reverse-proxies frontend + backend. **Cloudflare Tunnel** is the preferred ingress (no open inbound ports) but is never required; a plain Caddy-on-a-public-host deployment is equally supported.
- **Configuration** is entirely environment-driven (twelve-factor). One `.env` plus the compose file is enough to stand up a deployment. - **Configuration** is entirely environment-driven (twelve-factor). One `.env` plus the compose file is enough to stand up a deployment.
- **Migrations** run on backend start (or via an explicit job) so an image pull + restart is a complete upgrade. - **Migrations** run on backend start (or via an explicit job) so an image pull + restart is a complete upgrade.
- **Backups:** documented procedure for Postgres dump + object-store sync; restore is the inverse. - **Backups:** documented procedure for Postgres dump + object-store sync; restore is the inverse.
**Repository layout (as scaffolded):**
```
/backend # FastAPI, uv-managed. app/{api/v1, services (+privacy), repositories, models, schemas, integrations (auth/mailer), core}; migrations/ = Alembic
/deploy # docker-compose.yml, Caddyfile, .env.example
/.gitea/workflows # Gitea Actions: build images → Gitea registry
/frontend # Next.js (App Router, TS, Tailwind). app/ pages, lib/api (openapi-typescript client), components/ui, Dockerfile (standalone)
```
The compose stack runs `postgres` (pgvector image — includes `pgvector`; `pg_trgm` ships in contrib), `minio`, `backend`, and `caddy`. The **worker** container (same image as backend, worker mode) joins once queue-driven jobs exist. Phase 0 ships a minimal backend with `/health` (liveness) and `/health/ready` (Postgres reachability) to validate the deploy wiring before the data model lands.
## 13. Observability ## 13. Observability
- Structured (JSON) logs from backend and worker. - Structured (JSON) logs from backend and worker.
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
out
build
npm-debug.log*
.env*.local
README.md
+6
View File
@@ -0,0 +1,6 @@
/node_modules
/.next
/out
/build
next-env.d.ts
.env*.local
+24
View File
@@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
# Next standalone output: a minimal server with only the traced dependencies.
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
+56
View File
@@ -0,0 +1,56 @@
@import "tailwindcss";
/* Brand palette + type (docs/brand): warm ink + bronze + paper, serif display. */
@theme {
--color-bronze: #a06a42;
--color-bronze-deep: #8a5836;
--color-paper: #f7f3ec;
--color-ink: #1a1a17;
--font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
}
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant. */
:root {
--background: #f7f3ec;
--foreground: #1a1a17;
--muted: #6b6862;
--surface: #fffdf9;
--border: #e6ddcc;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #161410;
--foreground: #f2eee6;
--muted: #9a968e;
--surface: #211d17;
--border: #353029;
}
}
body {
/* A faint bronze warmth pooled at the top gives the flat paper some depth. */
background:
radial-gradient(
1100px 520px at 50% -8%,
color-mix(in srgb, var(--color-bronze) 9%, var(--background)),
var(--background) 60%
);
background-attachment: fixed;
color: var(--foreground);
font-family: var(--font-sans);
}
h1,
h2,
h3,
.font-serif {
font-family: var(--font-serif);
letter-spacing: -0.015em;
}
::selection {
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
}

Some files were not shown because too many files have changed in this diff Show More