Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a14fcc4ca | |||
| fc4cb0273e | |||
| 83f83ab641 | |||
| 064bb6ea65 | |||
| fbb9d0195c | |||
| 1f25eb2f21 | |||
| d6e2df4a61 | |||
| a799d101b5 | |||
| 0b9d72c878 | |||
| 2d0635e710 | |||
| 768d1b23d4 | |||
| 11f0f79866 | |||
| b8f5c35045 | |||
| 9e6cf6e5b7 | |||
| 828445a6b3 | |||
| 4921ce0776 | |||
| 6c3a9a89aa | |||
| fccc81a6cc | |||
| a5a79f01a7 | |||
| e2edd4b2f1 | |||
| 9f8dd960f4 | |||
| 00bfe8bfca | |||
| 5123c85397 | |||
| e5a8713293 | |||
| 64388b75bf | |||
| dffd05d303 | |||
| 4e115086e6 |
@@ -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
|
||||
@@ -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
|
||||
@@ -1,40 +0,0 @@
|
||||
name: build-images
|
||||
|
||||
# Gitea Actions build container images and push to the Gitea registry on
|
||||
# git.jpaul.io. Servers pull to deploy — no build on the host.
|
||||
#
|
||||
# Requires repo/org secrets: REGISTRY_USERNAME, REGISTRY_PASSWORD (a token with
|
||||
# package:write). Adjust the runner label to one your Gitea runner advertises.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
|
||||
env:
|
||||
REGISTRY: git.jpaul.io
|
||||
IMAGE_BASE: git.jpaul.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_BASE }}/backend:latest
|
||||
${{ env.IMAGE_BASE }}/backend:${{ github.sha }}
|
||||
@@ -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).
|
||||
- **Edge:** Caddy reverse proxy; optional Cloudflare Tunnel (preferred ingress, never required).
|
||||
- **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.
|
||||
|
||||
@@ -38,17 +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 # PRD.md, ARCHITECTURE.md
|
||||
/backend # FastAPI service (uv-managed). app/ = api / core (config, db); more layers land with the data model
|
||||
/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 — not yet scaffolded (Phase 0, after the deploy story)
|
||||
/frontend # Next.js (App Router, TS, Tailwind, shadcn-style UI). app/ pages, lib/api generated OpenAPI client, components/ui
|
||||
```
|
||||
|
||||
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 will use **Alembic**. 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
|
||||
|
||||
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
|
||||
2. Core data model from ARCHITECTURE §5 — start with User, Tree, TreeMembership, Person, Name, Relationship, Event, Place, Source, Citation, AuditEntry, soft-delete support
|
||||
@@ -72,6 +72,17 @@ Don't get ahead of the phases. GEDCOM lands before the assistant (so AI writes t
|
||||
|
||||
Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution.
|
||||
|
||||
## Brand
|
||||
|
||||
Visual identity lives in [docs/brand/](docs/brand/) (see its README for full guidance). Use these as the frontend's design tokens:
|
||||
|
||||
- **Ink** (primary text/marks): `#1A1A17` light / `#F2EEE6` dark
|
||||
- **Bronze** (accent, constant): `#A06A42`
|
||||
- **Paper** (knockout on bronze, constant): `#F7F3EC`
|
||||
- **Muted** (secondary text): `#6B6862` light / `#9A968E` dark
|
||||
|
||||
Wordmark is a serif (heritage register); UI body/secondary text is a humanist sans. Logo lockup: `docs/brand/provenance-logo.svg`; app icon/favicon: `docs/brand/provenance-icon.svg` and `favicon.svg`. Don't recolor outside the palette or add gradients/shadows — the look is flat and warm.
|
||||
|
||||
## Owner & contact
|
||||
|
||||
Maintainer: **Justin Paul** (`justin@jpaul.io`). This deployment targets a home lab: Authentik at `auth.jpaul.io` for auth, `mail.jpaul.io` for SMTP, behind Caddy + Cloudflare Tunnel.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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.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)]
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Versioned API surface. Mounts under /api/v1."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
auth,
|
||||
citations,
|
||||
events,
|
||||
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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
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}/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)
|
||||
@@ -0,0 +1,54 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.person import PersonCreate, PersonRead
|
||||
from app.services import person_service, tree_service
|
||||
|
||||
# Persons are nested under their tree (the tenant boundary).
|
||||
router = APIRouter(prefix="/trees", tags=["persons"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tree_id}/persons",
|
||||
response_model=PersonRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_person(
|
||||
tree_id: uuid.UUID, data: PersonCreate, session: SessionDep, current: CurrentUser
|
||||
) -> PersonRead:
|
||||
# get_tree enforces existence + view access; create_person enforces edit rights.
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
person = await person_service.create_person(
|
||||
session,
|
||||
actor=current,
|
||||
tree=tree,
|
||||
given=data.given,
|
||||
surname=data.surname,
|
||||
gender=data.gender,
|
||||
is_living=data.is_living,
|
||||
privacy_setting=data.privacy,
|
||||
notes=data.notes,
|
||||
)
|
||||
return PersonRead.model_validate(person)
|
||||
|
||||
|
||||
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
||||
async def list_persons(
|
||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> list[PersonRead]:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
||||
return [PersonRead.model_validate(p) for p in persons]
|
||||
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,50 @@
|
||||
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}/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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,33 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.tree import TreeCreate, TreeRead
|
||||
from app.services import tree_service
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||
|
||||
|
||||
@router.post("", response_model=TreeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||
tree = await tree_service.create_tree(
|
||||
session,
|
||||
owner=current,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
visibility=data.visibility,
|
||||
)
|
||||
return TreeRead.model_validate(tree)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TreeRead])
|
||||
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]:
|
||||
trees = await tree_service.list_trees_for_user(session, user=current)
|
||||
return [TreeRead.model_validate(t) for t in trees]
|
||||
|
||||
|
||||
@router.get("/{tree_id}", response_model=TreeRead)
|
||||
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
return TreeRead.model_validate(tree)
|
||||
@@ -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)
|
||||
@@ -27,6 +27,22 @@ class Settings(BaseSettings):
|
||||
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"
|
||||
|
||||
# --- 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:
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
"""Async database engine.
|
||||
"""Async database engine, session factory, and the FastAPI session dependency.
|
||||
|
||||
A single lazily-created async engine for the process. The repository layer
|
||||
(coming with the data model) will build sessions on top of this; for now it
|
||||
backs the readiness probe.
|
||||
The repository layer builds on ``get_session``; ``get_engine`` also backs the
|
||||
readiness probe. Everything is lazy so importing the app never opens a
|
||||
connection (important for tests and for ``--help``-style invocations).
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_engine: AsyncEngine | None = None
|
||||
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def get_engine() -> AsyncEngine:
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_async_engine(
|
||||
get_settings().database_url,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||
global _sessionmaker
|
||||
if _sessionmaker is None:
|
||||
_sessionmaker = async_sessionmaker(
|
||||
get_engine(), expire_on_commit=False, class_=AsyncSession
|
||||
)
|
||||
return _sessionmaker
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""FastAPI dependency. One session per request; commits are explicit in the
|
||||
service layer."""
|
||||
async with get_sessionmaker()() as session:
|
||||
yield session
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -1,17 +1,51 @@
|
||||
"""FastAPI application entrypoint.
|
||||
|
||||
Thin by design: wire settings and routers, expose the OpenAPI contract. All
|
||||
domain logic lives in the service layer (added with the data model). The
|
||||
versioned API will mount under ``/api/v1``; health probes stay at the root.
|
||||
Thin by design: wire settings, routers, and error handling, and expose the
|
||||
OpenAPI contract. All domain logic lives in the service layer; the privacy
|
||||
engine is the single enforcement point for reads.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
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,
|
||||
@@ -19,6 +53,8 @@ def create_app() -> FastAPI:
|
||||
description="Provenance API — family and land provenance.",
|
||||
)
|
||||
app.include_router(health_router)
|
||||
app.include_router(api_router)
|
||||
_register_error_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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.person import Name, Person
|
||||
@@ -25,4 +26,6 @@ __all__ = [
|
||||
"Source",
|
||||
"Citation",
|
||||
"AuditEntry",
|
||||
"Session",
|
||||
"UserToken",
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
@@ -55,3 +55,8 @@ class CitationConfidence(enum.StrEnum):
|
||||
class AuditActorType(enum.StrEnum):
|
||||
user = "user"
|
||||
assistant = "assistant"
|
||||
|
||||
|
||||
class TokenPurpose(enum.StrEnum):
|
||||
email_verify = "email_verify"
|
||||
password_reset = "password_reset"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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_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()
|
||||
@@ -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)."""
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Person service. Writes require editor rights on the tree; reads run every
|
||||
person through the privacy engine. Each returned Person gets a transient
|
||||
``primary_name`` for display (not persisted).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import PersonPrivacy
|
||||
from app.models.person import Name, Person
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.services import privacy
|
||||
from app.services.audit import record_audit
|
||||
from app.services.exceptions import Forbidden, 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 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
|
||||
@@ -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,121 @@
|
||||
"""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_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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tree service. Creating a tree also creates the owner's TreeMembership (the
|
||||
authorization basis) and an audit entry. Reads go through the privacy engine.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import MembershipRole, TreeVisibility
|
||||
from app.models.tree import Tree, TreeMembership
|
||||
from app.models.user import User
|
||||
from app.repositories.base import BaseRepository
|
||||
from app.services import privacy
|
||||
from app.services.audit import record_audit
|
||||
from app.services.exceptions import Forbidden, NotFound
|
||||
|
||||
|
||||
async def create_tree(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
owner: User,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
visibility: TreeVisibility = TreeVisibility.private,
|
||||
) -> Tree:
|
||||
tree = Tree(owner_id=owner.id, name=name, description=description, visibility=visibility)
|
||||
session.add(tree)
|
||||
await session.flush() # assign tree.id
|
||||
session.add(TreeMembership(tree_id=tree.id, user_id=owner.id, role=MembershipRole.owner))
|
||||
record_audit(
|
||||
session,
|
||||
action="create",
|
||||
entity_type="Tree",
|
||||
entity_id=tree.id,
|
||||
tree_id=tree.id,
|
||||
actor_user_id=owner.id,
|
||||
after={"name": name, "visibility": visibility.value},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(tree)
|
||||
return tree
|
||||
|
||||
|
||||
async def list_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
||||
stmt = (
|
||||
select(Tree)
|
||||
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
|
||||
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_(None))
|
||||
.order_by(Tree.created_at)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid.UUID) -> Tree:
|
||||
tree = await BaseRepository(session, Tree).get(tree_id)
|
||||
if tree is None:
|
||||
raise NotFound("tree not found")
|
||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||
raise Forbidden("not permitted to view this tree")
|
||||
return tree
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"asyncpg>=0.30",
|
||||
"alembic>=1.14",
|
||||
"argon2-cffi>=23.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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
|
||||
from app.core.db import get_session
|
||||
from app.integrations.mailer.base import Mailer
|
||||
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))
|
||||
|
||||
|
||||
_mailer = CapturingMailer()
|
||||
|
||||
|
||||
@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()
|
||||
app.dependency_overrides[get_session] = _override_session
|
||||
app.dependency_overrides[get_mailer] = lambda: _mailer
|
||||
|
||||
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}"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +1,10 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
@@ -46,6 +50,49 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi-bindings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi-bindings"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncpg"
|
||||
version = "0.31.0"
|
||||
@@ -87,6 +134,51 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.1"
|
||||
@@ -353,6 +445,7 @@ version = "0.0.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "pydantic" },
|
||||
@@ -372,6 +465,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.14" },
|
||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||
{ name = "asyncpg", specifier = ">=0.30" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "pydantic", specifier = ">=2.9" },
|
||||
@@ -388,6 +482,15 @@ dev = [
|
||||
{ name = "ruff", specifier = ">=0.8" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
# --- 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
|
||||
@@ -22,8 +26,27 @@ 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
|
||||
|
||||
@@ -15,11 +15,8 @@
|
||||
reverse_proxy backend:8000
|
||||
}
|
||||
|
||||
# Frontend (Next.js) — not yet deployed in Phase 0. Uncomment when it lands.
|
||||
# handle {
|
||||
# reverse_proxy frontend:3000
|
||||
# }
|
||||
# Everything else -> the Next.js frontend.
|
||||
handle {
|
||||
respond "Provenance — Phase 0. Backend health at /health; frontend not yet deployed." 200
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
@@ -2,6 +2,11 @@ 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:
|
||||
@@ -36,9 +41,9 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
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}
|
||||
@@ -57,6 +62,16 @@ services:
|
||||
start_period: 20s
|
||||
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:
|
||||
@@ -72,8 +87,33 @@ services:
|
||||
- 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:
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- *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
|
||||
|
||||
@@ -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.
|
||||
- **Relationship** — typed edge between two Persons within a Tree: parent–child (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.
|
||||
- **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)
|
||||
- **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).
|
||||
- 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.
|
||||
- *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
|
||||
|
||||
@@ -168,8 +170,10 @@ Jobs are idempotent and retryable; an external failure degrades gracefully rathe
|
||||
|
||||
## 12. Deployment & CI/CD
|
||||
|
||||
- **Images** are built by **Gitea Actions** on `git.jpaul.io` and pushed to the **Gitea container registry**.
|
||||
- Servers **pull** new images to deploy — no build on the host.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -178,10 +182,10 @@ Jobs are idempotent and retryable; an external failure degrades gracefully rathe
|
||||
**Repository layout (as scaffolded):**
|
||||
|
||||
```
|
||||
/backend # FastAPI service, uv-managed (app/ = api, core; service/repository/domain land with the data model)
|
||||
/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 (pending)
|
||||
/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.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Provenance — Brand
|
||||
|
||||
Draft v0.1. The visual identity for Provenance: an **Origin mark** (primary logo) and a **monogram tile** (app icon / favicon), in a warm ink-and-bronze palette.
|
||||
|
||||
## Concept
|
||||
|
||||
The mark is a survey-datum / origin point: a ring with cardinal ticks (a surveyor's monument — the **land / property** side of the product) enclosing four nodes connected to a center point (a **family graph** — the **people** side). One mark for both halves of what Provenance does. The bronze echoes survey-marker disks and aged document seals — heritage without sepia cliché.
|
||||
|
||||
## Palette
|
||||
|
||||
| Role | Light | Dark | Hex notes |
|
||||
|------|-------|------|-----------|
|
||||
| Ink (primary text/marks) | `#1A1A17` | `#F2EEE6` | warm near-black / warm off-white |
|
||||
| Bronze (accent) | `#A06A42` | `#A06A42` | constant in both modes |
|
||||
| Paper (knockout on bronze) | `#F7F3EC` | `#F7F3EC` | constant |
|
||||
| Muted (tagline/secondary) | `#6B6862` | `#9A968E` | |
|
||||
|
||||
The SVG assets carry an embedded `prefers-color-scheme` rule, so ink and muted tones auto-adapt to light/dark; bronze and paper are intentionally fixed (bronze is mid-tone and reads on both).
|
||||
|
||||
## Typography
|
||||
|
||||
- **Wordmark:** a refined transitional **serif** (the heritage/archival register). The wordmark in these assets is **outlined to vector paths**, so the files render identically everywhere with no font dependency. For production UI headings, pair with a comparable licensed serif (e.g. a Times/Garamond-class face).
|
||||
- **Tagline & secondary:** a clean humanist/grotesque **sans**.
|
||||
- **Tagline:** *where it came from matters* — sentence case, never title case.
|
||||
|
||||
## Assets
|
||||
|
||||
| File | Use |
|
||||
|------|-----|
|
||||
| `provenance-logo.svg` | Primary horizontal lockup — mark + wordmark + tagline |
|
||||
| `provenance-logo-plain.svg` | Lockup without tagline (tight spaces, headers) |
|
||||
| `provenance-mark.svg` | Mark only — square, for avatars, small placements, loading states |
|
||||
| `provenance-icon.svg` | App icon — 512px bronze monogram tile |
|
||||
| `favicon.svg` | Favicon — 48px monogram tile |
|
||||
| `generate.py` | Reproducible generator for all of the above |
|
||||
|
||||
## Usage notes
|
||||
|
||||
- **Clear space:** keep padding around the lockup at least the height of the mark's center-to-tick distance (≈ the ring radius). Don't crowd it.
|
||||
- **Minimum size:** the full lockup stays legible down to ~140px wide; below that, use the mark or the icon.
|
||||
- **Don't:** recolor outside the palette, add gradients/shadows, stretch, or rotate. Don't put the ink lockup on a busy or mid-tone background where it loses contrast — use the icon tile instead.
|
||||
- **Backgrounds:** the lockup and mark are transparent and adapt to light/dark. The icon tile supplies its own bronze background.
|
||||
|
||||
## Regenerating
|
||||
|
||||
```sh
|
||||
python3 docs/brand/generate.py
|
||||
```
|
||||
|
||||
Requires `matplotlib` and the Liberation Serif/Sans fonts (or edit the font paths at the top of the script). Outputs all SVGs into this directory.
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" role="img" aria-label="Provenance icon">
|
||||
<title>Provenance icon</title>
|
||||
<rect x="0" y="0" width="48" height="48" rx="8.64" fill="#A06A42"/>
|
||||
<g transform="translate(16.32 14.26)"><path d="M12.47 5.77Q12.47 3.37 11.35 2.34Q10.23 1.31 7.58 1.31L6.16 1.31L6.16 10.54L7.67 10.54Q10.13 10.54 11.29 9.42Q12.47 8.3 12.47 5.77ZM6.16 11.84L6.16 18.33L9.26 18.72L9.26 19.49L1.05 19.49L1.05 18.72L3.36 18.33L3.36 1.15L0.86 0.77L0.86 0.0L8.21 0.0Q15.36 0.0 15.36 5.74Q15.36 8.73 13.55 10.29Q11.74 11.84 8.36 11.84L6.16 11.84Z" fill="#F7F3EC"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Provenance brand assets as portable, font-independent SVGs."""
|
||||
import os
|
||||
from matplotlib.textpath import TextPath
|
||||
from matplotlib.font_manager import FontProperties
|
||||
from matplotlib.path import Path
|
||||
|
||||
SERIF = "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf"
|
||||
SANS = "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
|
||||
|
||||
INK = "#1A1A17"
|
||||
INK_DARK = "#F2EEE6"
|
||||
BRONZE = "#A06A42"
|
||||
PAPER = "#F7F3EC"
|
||||
MUTED = "#6B6862"
|
||||
MUTED_DARK = "#9A968E"
|
||||
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)))
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def text_to_path(s, font_path, size):
|
||||
"""Return (d_string, width, height). Coordinates: top-left origin, y down."""
|
||||
fp = FontProperties(fname=font_path)
|
||||
tp = TextPath((0, 0), s, size=size, prop=fp)
|
||||
verts, codes = tp.vertices, tp.codes
|
||||
xs = verts[:, 0]
|
||||
ys = verts[:, 1]
|
||||
xmin, xmax = xs.min(), xs.max()
|
||||
ymin, ymax = ys.min(), ys.max()
|
||||
|
||||
def tx(x):
|
||||
return round(float(x - xmin), 2)
|
||||
|
||||
def ty(y):
|
||||
return round(float(ymax - y), 2)
|
||||
|
||||
d = []
|
||||
i = 0
|
||||
n = len(codes)
|
||||
while i < n:
|
||||
c = codes[i]
|
||||
if c == Path.MOVETO:
|
||||
x, y = verts[i]
|
||||
d.append(f"M{tx(x)} {ty(y)}")
|
||||
i += 1
|
||||
elif c == Path.LINETO:
|
||||
x, y = verts[i]
|
||||
d.append(f"L{tx(x)} {ty(y)}")
|
||||
i += 1
|
||||
elif c == Path.CURVE3:
|
||||
x1, y1 = verts[i]
|
||||
x2, y2 = verts[i + 1]
|
||||
d.append(f"Q{tx(x1)} {ty(y1)} {tx(x2)} {ty(y2)}")
|
||||
i += 2
|
||||
elif c == Path.CURVE4:
|
||||
x1, y1 = verts[i]
|
||||
x2, y2 = verts[i + 1]
|
||||
x3, y3 = verts[i + 2]
|
||||
d.append(f"C{tx(x1)} {ty(y1)} {tx(x2)} {ty(y2)} {tx(x3)} {ty(y3)}")
|
||||
i += 3
|
||||
elif c == Path.CLOSEPOLY:
|
||||
d.append("Z")
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return "".join(d), round(float(xmax - xmin), 2), round(float(ymax - ymin), 2)
|
||||
|
||||
|
||||
def origin_mark(cx, cy, R, conn_sw=1.5, tick_sw=2):
|
||||
"""Origin/survey-datum mark primitives as an SVG fragment string.
|
||||
Ink elements get class='ink'; bronze elements class='br'."""
|
||||
import math
|
||||
diag = R * 0.7071
|
||||
nodes = [(cx + diag, cy - diag), (cx - diag, cy - diag),
|
||||
(cx + diag, cy + diag), (cx - diag, cy + diag)]
|
||||
parts = []
|
||||
# ring (bronze)
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="{R}" class="br-s" '
|
||||
f'fill="none" stroke-width="{tick_sw}"/>')
|
||||
# cardinal ticks (bronze) crossing the ring
|
||||
t_in, t_out = R - 4, R + 4
|
||||
for (dx, dy) in [(0, -1), (0, 1), (1, 0), (-1, 0)]:
|
||||
x1, y1 = cx + dx * t_in, cy + dy * t_in
|
||||
x2, y2 = cx + dx * t_out, cy + dy * t_out
|
||||
parts.append(f'<line x1="{round(x1,2)}" y1="{round(y1,2)}" '
|
||||
f'x2="{round(x2,2)}" y2="{round(y2,2)}" class="br-s" '
|
||||
f'stroke-width="{tick_sw}" stroke-linecap="round"/>')
|
||||
# connectors (ink) center -> diagonal nodes
|
||||
for (nx, ny) in nodes:
|
||||
parts.append(f'<line x1="{cx}" y1="{cy}" x2="{round(nx,2)}" '
|
||||
f'y2="{round(ny,2)}" class="ink-s" fill="none" '
|
||||
f'stroke-width="{conn_sw}"/>')
|
||||
# diagonal nodes (ink)
|
||||
nr = max(2.0, R * 0.105)
|
||||
for (nx, ny) in nodes:
|
||||
parts.append(f'<circle cx="{round(nx,2)}" cy="{round(ny,2)}" '
|
||||
f'r="{round(nr,2)}" class="ink"/>')
|
||||
# center dot (ink)
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="{round(R*0.15,2)}" '
|
||||
f'class="ink"/>')
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
STYLE = f""".ink{{fill:{INK}}}.ink-s{{stroke:{INK}}}.br{{fill:{BRONZE}}}.br-s{{stroke:{BRONZE}}}.muted{{fill:{MUTED}}}
|
||||
@media (prefers-color-scheme:dark){{.ink{{fill:{INK_DARK}}}.ink-s{{stroke:{INK_DARK}}}.muted{{fill:{MUTED_DARK}}}}}"""
|
||||
|
||||
# ---- 1. Primary horizontal lockup ----
|
||||
WM_SIZE = 64
|
||||
TAG_SIZE = 15
|
||||
wm_d, wm_w, wm_h = text_to_path("Provenance", SERIF, WM_SIZE)
|
||||
tag_d, tag_w, tag_h = text_to_path("where it came from matters", SANS, TAG_SIZE)
|
||||
|
||||
pad = 28
|
||||
R = 30
|
||||
mark_box = 2 * (R + 6)
|
||||
gap = 26
|
||||
block_gap = 12
|
||||
text_block_h = wm_h + block_gap + tag_h
|
||||
content_h = max(mark_box, text_block_h)
|
||||
H = round(pad * 2 + content_h, 2)
|
||||
mark_cx = pad + (R + 6)
|
||||
mark_cy = round(H / 2, 2)
|
||||
text_x = pad + mark_box + gap
|
||||
W = round(text_x + max(wm_w, tag_w) + pad, 2)
|
||||
# vertically center the text block
|
||||
block_top = (H - text_block_h) / 2
|
||||
wm_y = round(block_top, 2)
|
||||
tag_y = round(block_top + wm_h + block_gap, 2)
|
||||
|
||||
logo = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}" role="img" aria-label="Provenance">
|
||||
<title>Provenance</title>
|
||||
<style>{STYLE}</style>
|
||||
{origin_mark(mark_cx, mark_cy, R)}
|
||||
<g transform="translate({text_x} {wm_y})"><path d="{wm_d}" class="ink"/></g>
|
||||
<g transform="translate({round(text_x+0.5,2)} {tag_y})"><path d="{tag_d}" class="muted"/></g>
|
||||
</svg>
|
||||
'''
|
||||
open(f"{OUT}/provenance-logo.svg", "w").write(logo)
|
||||
|
||||
# ---- 1b. Lockup without tagline ----
|
||||
H2 = round(pad * 2 + max(mark_box, wm_h), 2)
|
||||
mcy2 = round(H2 / 2, 2)
|
||||
wm_y2 = round((H2 - wm_h) / 2, 2)
|
||||
W2 = round(text_x + wm_w + pad, 2)
|
||||
logo2 = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{W2}" height="{H2}" viewBox="0 0 {W2} {H2}" role="img" aria-label="Provenance">
|
||||
<title>Provenance</title>
|
||||
<style>{STYLE}</style>
|
||||
{origin_mark(mark_cx, mcy2, R)}
|
||||
<g transform="translate({text_x} {wm_y2})"><path d="{wm_d}" class="ink"/></g>
|
||||
</svg>
|
||||
'''
|
||||
open(f"{OUT}/provenance-logo-plain.svg", "w").write(logo2)
|
||||
|
||||
# ---- 2. Mark only (square) ----
|
||||
S = 96
|
||||
c = S / 2
|
||||
markonly = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{S}" height="{S}" viewBox="0 0 {S} {S}" role="img" aria-label="Provenance mark">
|
||||
<title>Provenance mark</title>
|
||||
<style>{STYLE}</style>
|
||||
{origin_mark(c, c, 34, conn_sw=1.6, tick_sw=2.2)}
|
||||
</svg>
|
||||
'''
|
||||
open(f"{OUT}/provenance-mark.svg", "w").write(markonly)
|
||||
|
||||
# ---- 3. App icon / monogram tile (square) ----
|
||||
def monogram(side, radius_ratio=0.22):
|
||||
p_size = side * 0.62
|
||||
pd, pw, ph = text_to_path("P", SERIF, p_size)
|
||||
px = round((side - pw) / 2, 2)
|
||||
py = round((side - ph) / 2, 2)
|
||||
rx = round(side * radius_ratio, 2)
|
||||
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{side}" height="{side}" viewBox="0 0 {side} {side}" role="img" aria-label="Provenance icon">
|
||||
<title>Provenance icon</title>
|
||||
<rect x="0" y="0" width="{side}" height="{side}" rx="{rx}" fill="{BRONZE}"/>
|
||||
<g transform="translate({px} {py})"><path d="{pd}" fill="{PAPER}"/></g>
|
||||
</svg>
|
||||
'''
|
||||
|
||||
open(f"{OUT}/provenance-icon.svg", "w").write(monogram(512))
|
||||
open(f"{OUT}/favicon.svg", "w").write(monogram(48, radius_ratio=0.18))
|
||||
|
||||
print("WROTE:")
|
||||
for f in sorted(os.listdir(OUT)):
|
||||
p = os.path.join(OUT, f)
|
||||
print(f" {f} ({os.path.getsize(p)} bytes)")
|
||||
print(f"\nlogo lockup: {W} x {H}")
|
||||
print(f"wordmark path size: w={wm_w} h={wm_h}")
|
||||
print(f"tagline path size: w={tag_w} h={tag_h}")
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Provenance icon">
|
||||
<title>Provenance icon</title>
|
||||
<rect x="0" y="0" width="512" height="512" rx="112.64" fill="#A06A42"/>
|
||||
<g transform="translate(174.08 152.06)"><path d="M132.98 61.55Q132.98 35.96 121.02 25.0Q109.12 13.99 80.9 13.99L65.72 13.99L65.72 112.39L81.84 112.39Q108.03 112.39 120.48 100.49Q132.98 88.54 132.98 61.55ZM65.72 126.33L65.72 195.47L98.75 199.64L98.75 207.87L11.16 207.87L11.16 199.64L35.81 195.47L35.81 12.25L9.13 8.23L9.13 0.0L87.59 0.0Q163.83 0.0 163.83 61.26Q163.83 93.15 144.53 109.76Q125.24 126.33 89.13 126.33L65.72 126.33Z" fill="#F7F3EC"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 689 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="450.31" height="128" viewBox="0 0 450.31 128" role="img" aria-label="Provenance">
|
||||
<title>Provenance</title>
|
||||
<style>.ink{fill:#1A1A17}.ink-s{stroke:#1A1A17}.br{fill:#A06A42}.br-s{stroke:#A06A42}.muted{fill:#6B6862}
|
||||
@media (prefers-color-scheme:dark){.ink{fill:#F2EEE6}.ink-s{stroke:#F2EEE6}.muted{fill:#9A968E}}</style>
|
||||
<circle cx="64" cy="64.0" r="30" class="br-s" fill="none" stroke-width="2"/>
|
||||
<line x1="64" y1="38.0" x2="64" y2="30.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="64" y1="90.0" x2="64" y2="98.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="90" y1="64.0" x2="98" y2="64.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="38" y1="64.0" x2="30" y2="64.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="64" y1="64.0" x2="85.21" y2="42.79" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||
<line x1="64" y1="64.0" x2="42.79" y2="42.79" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||
<line x1="64" y1="64.0" x2="85.21" y2="85.21" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||
<line x1="64" y1="64.0" x2="42.79" y2="85.21" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||
<circle cx="85.21" cy="42.79" r="3.15" class="ink"/>
|
||||
<circle cx="42.79" cy="42.79" r="3.15" class="ink"/>
|
||||
<circle cx="85.21" cy="85.21" r="3.15" class="ink"/>
|
||||
<circle cx="42.79" cy="85.21" r="3.15" class="ink"/>
|
||||
<circle cx="64" cy="64.0" r="4.5" class="ink"/>
|
||||
<g transform="translate(126 42.73)"><path d="M26.81 12.41Q26.81 7.25 24.4 5.04Q22.0 2.82 16.31 2.82L13.25 2.82L13.25 22.66L16.5 22.66Q21.78 22.66 24.29 20.26Q26.81 17.85 26.81 12.41ZM13.25 25.47L13.25 39.41L19.91 40.25L19.91 41.91L2.25 41.91L2.25 40.25L7.22 39.41L7.22 2.47L1.84 1.66L1.84 0.0L17.66 0.0Q33.03 0.0 33.03 12.35Q33.03 18.78 29.14 22.13Q25.25 25.47 17.97 25.47L13.25 25.47ZM56.34 11.75L56.34 19.69L55.0 19.69L53.18 16.25Q51.62 16.25 49.48 16.68Q47.34 17.1 45.78 17.78L45.78 39.72L50.81 40.5L50.81 41.91L36.87 41.91L36.87 40.5L40.59 39.72L40.59 14.72L36.87 13.94L36.87 12.53L45.43 12.53L45.72 16.19Q47.59 14.63 50.79 13.19Q54.0 11.75 55.87 11.75L56.34 11.75ZM86.47 27.07Q86.47 42.54 72.72 42.54Q66.1 42.54 62.72 38.57Q59.35 34.6 59.35 27.07Q59.35 19.63 62.72 15.69Q66.1 11.75 72.97 11.75Q79.66 11.75 83.06 15.61Q86.47 19.47 86.47 27.07ZM80.85 27.07Q80.85 20.32 78.88 17.29Q76.91 14.25 72.72 14.25Q68.63 14.25 66.8 17.16Q64.97 20.07 64.97 27.07Q64.97 34.16 66.83 37.12Q68.69 40.07 72.72 40.07Q76.85 40.07 78.85 37.01Q80.85 33.94 80.85 27.07ZM106.32 42.54L104.0 42.54L91.91 14.72L88.91 13.94L88.91 12.53L102.6 12.53L102.6 13.94L97.94 14.78L106.5 35.07L114.69 14.72L110.04 13.94L110.04 12.53L120.91 12.53L120.91 13.94L118.1 14.6L106.32 42.54ZM129.04 27.13L129.04 27.69Q129.04 32.0 129.99 34.39Q130.94 36.78 132.92 38.03Q134.91 39.28 138.13 39.28Q139.82 39.28 142.13 39.0Q144.44 38.72 145.94 38.38L145.94 40.13Q144.44 41.1 141.86 41.82Q139.29 42.54 136.6 42.54Q129.75 42.54 126.58 38.85Q123.41 35.16 123.41 27.0Q123.41 19.32 126.63 15.54Q129.85 11.75 135.82 11.75Q147.1 11.75 147.1 24.57L147.1 27.13L129.04 27.13ZM135.82 14.25Q132.57 14.25 130.83 16.88Q129.1 19.5 129.1 24.63L141.66 24.63Q141.66 19.03 140.22 16.64Q138.79 14.25 135.82 14.25ZM159.44 14.91Q161.84 13.53 164.56 12.64Q167.28 11.75 169.09 11.75Q172.9 11.75 174.84 13.97Q176.78 16.19 176.78 20.41L176.78 39.72L180.34 40.5L180.34 41.91L167.69 41.91L167.69 40.5L171.59 39.72L171.59 20.97Q171.59 18.38 170.32 16.9Q169.06 15.41 166.4 15.41Q163.59 15.41 159.5 16.32L159.5 39.72L163.47 40.5L163.47 41.91L150.78 41.91L150.78 40.5L154.31 39.72L154.31 14.72L150.78 13.94L150.78 12.53L159.15 12.53L159.44 14.91ZM195.84 11.88Q200.65 11.88 202.92 13.85Q205.19 15.82 205.19 19.88L205.19 39.72L208.84 40.5L208.84 41.91L200.78 41.91L200.19 38.97Q196.62 42.54 191.09 42.54Q183.56 42.54 183.56 33.78Q183.56 30.85 184.7 28.93Q185.84 27.0 188.34 25.99Q190.84 24.97 195.59 24.88L200.0 24.75L200.0 20.16Q200.0 17.13 198.89 15.69Q197.78 14.25 195.47 14.25Q192.34 14.25 189.75 15.72L188.69 19.38L186.94 19.38L186.94 12.97Q192.0 11.88 195.84 11.88ZM200.0 26.94L195.9 27.07Q191.72 27.22 190.23 28.69Q188.75 30.16 188.75 33.6Q188.75 39.1 193.22 39.1Q195.34 39.1 196.89 38.62Q198.44 38.13 200.0 37.38L200.0 26.94ZM219.85 14.91Q222.25 13.53 224.97 12.64Q227.69 11.75 229.5 11.75Q233.31 11.75 235.25 13.97Q237.19 16.19 237.19 20.41L237.19 39.72L240.75 40.5L240.75 41.91L228.1 41.91L228.1 40.5L232.0 39.72L232.0 20.97Q232.0 18.38 230.73 16.9Q229.47 15.41 226.81 15.41Q224.0 15.41 219.91 16.32L219.91 39.72L223.88 40.5L223.88 41.91L211.19 41.91L211.19 40.5L214.72 39.72L214.72 14.72L211.19 13.94L211.19 12.53L219.56 12.53L219.85 14.91ZM268.16 40.13Q266.63 41.25 263.94 41.9Q261.25 42.54 258.44 42.54Q244.16 42.54 244.16 27.0Q244.16 19.66 247.8 15.71Q251.44 11.75 258.22 11.75Q262.44 11.75 267.44 12.72L267.44 20.91L265.72 20.91L264.38 15.72Q261.78 14.25 258.16 14.25Q249.78 14.25 249.78 27.0Q249.78 33.63 252.33 36.46Q254.88 39.28 260.22 39.28Q264.78 39.28 268.16 38.25L268.16 40.13ZM278.25 27.13L278.25 27.69Q278.25 32.0 279.2 34.39Q280.16 36.78 282.13 38.03Q284.12 39.28 287.35 39.28Q289.04 39.28 291.35 39.0Q293.66 38.72 295.16 38.38L295.16 40.13Q293.66 41.1 291.07 41.82Q288.5 42.54 285.81 42.54Q278.97 42.54 275.8 38.85Q272.62 35.16 272.62 27.0Q272.62 19.32 275.85 15.54Q279.06 11.75 285.04 11.75Q296.31 11.75 296.31 24.57L296.31 27.13L278.25 27.13ZM285.04 14.25Q281.79 14.25 280.05 16.88Q278.31 19.5 278.31 24.63L290.88 24.63Q290.88 19.03 289.44 16.64Q288.0 14.25 285.04 14.25Z" class="ink"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Provenance mark">
|
||||
<title>Provenance mark</title>
|
||||
<style>.ink{fill:#1A1A17}.ink-s{stroke:#1A1A17}.br{fill:#A06A42}.br-s{stroke:#A06A42}.muted{fill:#6B6862}
|
||||
@media (prefers-color-scheme:dark){.ink{fill:#F2EEE6}.ink-s{stroke:#F2EEE6}.muted{fill:#9A968E}}</style>
|
||||
<circle cx="48.0" cy="48.0" r="34" class="br-s" fill="none" stroke-width="2.2"/>
|
||||
<line x1="48.0" y1="18.0" x2="48.0" y2="10.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<line x1="48.0" y1="78.0" x2="48.0" y2="86.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<line x1="78.0" y1="48.0" x2="86.0" y2="48.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<line x1="18.0" y1="48.0" x2="10.0" y2="48.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<line x1="48.0" y1="48.0" x2="72.04" y2="23.96" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||
<line x1="48.0" y1="48.0" x2="23.96" y2="23.96" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||
<line x1="48.0" y1="48.0" x2="72.04" y2="72.04" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||
<line x1="48.0" y1="48.0" x2="23.96" y2="72.04" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||
<circle cx="72.04" cy="23.96" r="3.57" class="ink"/>
|
||||
<circle cx="23.96" cy="23.96" r="3.57" class="ink"/>
|
||||
<circle cx="72.04" cy="72.04" r="3.57" class="ink"/>
|
||||
<circle cx="23.96" cy="72.04" r="3.57" class="ink"/>
|
||||
<circle cx="48.0" cy="48.0" r="5.1" class="ink"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
npm-debug.log*
|
||||
.env*.local
|
||||
README.md
|
||||
@@ -0,0 +1,6 @@
|
||||
/node_modules
|
||||
/.next
|
||||
/out
|
||||
/build
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fraunces, Inter } from "next/font/google";
|
||||
import Link from "next/link";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
// Heritage display serif + clean humanist sans (per docs/brand typography).
|
||||
const serif = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-fraunces",
|
||||
display: "swap",
|
||||
axes: ["opsz"],
|
||||
});
|
||||
const sans = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Provenance — where it came from matters",
|
||||
description:
|
||||
"Trace your family and your land in one place — every fact linked to the record it came from. Self-hosted, sourced, and yours to keep.",
|
||||
icons: { icon: "/favicon.svg" },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
||||
<body className="flex min-h-screen flex-col antialiased">
|
||||
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-5 py-3.5">
|
||||
<Link href="/" className="flex items-center" aria-label="Provenance — home">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-[var(--foreground)]">
|
||||
Trees
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium transition-colors hover:border-bronze hover:text-bronze"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-10">{children}</main>
|
||||
|
||||
<footer className="border-t border-[var(--border)]">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-5 py-6 text-sm text-[var(--muted)]">
|
||||
<span className="font-serif text-base italic">where it came from matters</span>
|
||||
<span>Self-hosted · source-available · your data, your infrastructure</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error } = await api.POST("/api/v1/auth/login", { body: { email, password } });
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setError("Invalid email or password.");
|
||||
return;
|
||||
}
|
||||
router.push("/trees");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mx-auto max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-[var(--muted)]">
|
||||
No account?{" "}
|
||||
<Link href="/register" className="text-bronze underline">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { BadgeCheck, MapPin, ShieldCheck, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Users,
|
||||
title: "Family and land, together",
|
||||
body: "People, relationships, and life events alongside property and chain-of-title — one documented story of where you come from.",
|
||||
},
|
||||
{
|
||||
icon: BadgeCheck,
|
||||
title: "Sourced or it didn't happen",
|
||||
body: "Every fact can carry a citation back to the record it came from. Sources are first-class, reusable, and visible.",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: "Yours to keep",
|
||||
body: "Self-hosted and source-available. Living people protected by default. Open formats — export anytime, run it anywhere.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="space-y-20 py-6 sm:py-12">
|
||||
<section className="grid items-center gap-10 sm:grid-cols-[1.3fr_1fr]">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
|
||||
Family · Land · Provenance
|
||||
</p>
|
||||
<h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
|
||||
Where it came from{" "}
|
||||
<span className="italic text-bronze">matters</span>.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
|
||||
Trace your family and your land in one place — every name, every parcel, every claim
|
||||
linked to the record it came from. Self-hosted, sourced, and yours to keep.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link href="/register">
|
||||
<Button size="lg">Create your account</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button size="lg" variant="outline">
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden justify-self-end sm:block">
|
||||
<div className="relative grid h-64 w-64 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] shadow-[0_24px_60px_-24px_rgba(160,106,66,0.35)]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/provenance-mark.svg" alt="" className="h-36 w-36" />
|
||||
<MapPin className="absolute -right-2 top-10 h-7 w-7 text-bronze" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 sm:grid-cols-3">
|
||||
{features.map((f) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-[0_1px_2px_rgba(26,26,23,0.04)]"
|
||||
>
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-bronze/12 text-bronze">
|
||||
<f.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-lg font-semibold">{f.title}</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">{f.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error } = await api.POST("/api/v1/auth/register", {
|
||||
body: { email, password, display_name: displayName || null },
|
||||
});
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setError("Could not register. The email may already be in use, or the password is too short (min 8).");
|
||||
return;
|
||||
}
|
||||
router.push("/trees");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mx-auto max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Create your account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? "Creating…" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-[var(--muted)]">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-bronze underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
|
||||
export default function TreeDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const treeId = params.id;
|
||||
|
||||
const [persons, setPersons] = useState<Person[]>([]);
|
||||
const [given, setGiven] = useState("");
|
||||
const [surname, setSurname] = useState("");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
});
|
||||
if (response.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setPersons(data ?? []);
|
||||
setReady(true);
|
||||
}, [router, treeId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function addPerson(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!given.trim() && !surname.trim()) return;
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { given: given || null, surname: surname || null },
|
||||
});
|
||||
if (!error) {
|
||||
setGiven("");
|
||||
setSurname("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
||||
← All trees
|
||||
</Link>
|
||||
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
|
||||
Sources →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Add a person</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={addPerson} className="flex gap-2">
|
||||
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
|
||||
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
|
||||
<Button type="submit">Add</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
||||
{persons.length === 0 ? (
|
||||
<p className="text-[var(--muted)]">No people yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{persons.map((person) => (
|
||||
<li key={person.id}>
|
||||
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
||||
<Card className="transition-colors hover:border-bronze/50">
|
||||
<CardContent className="p-4">
|
||||
{person.primary_name ?? (
|
||||
<span className="text-[var(--muted)]">Unnamed</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
type Event = components["schemas"]["EventRead"];
|
||||
type Relationship = components["schemas"]["RelationshipRead"];
|
||||
type Qualifier = components["schemas"]["ParentChildQualifier"];
|
||||
type RelCreate = components["schemas"]["RelationshipCreate"];
|
||||
type Source = components["schemas"]["SourceRead"];
|
||||
type Citation = components["schemas"]["CitationRead"];
|
||||
type CitationCreate = components["schemas"]["CitationCreate"];
|
||||
|
||||
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
|
||||
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
|
||||
|
||||
export default function PersonDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string; personId: string }>();
|
||||
const treeId = params.id;
|
||||
const personId = params.personId;
|
||||
|
||||
const [person, setPerson] = useState<Person | null>(null);
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [rels, setRels] = useState<Relationship[]>([]);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [citations, setCitations] = useState<Citation[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [evType, setEvType] = useState("birth");
|
||||
const [evDate, setEvDate] = useState("");
|
||||
|
||||
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
||||
const [relOther, setRelOther] = useState("");
|
||||
const [relQual, setRelQual] = useState<Qualifier>("biological");
|
||||
|
||||
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
|
||||
const [citeFor, setCiteFor] = useState<string | null>(null);
|
||||
const [citeSource, setCiteSource] = useState("");
|
||||
const [citePage, setCitePage] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
});
|
||||
if (p.response.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setPerson(p.data ?? null);
|
||||
const [all, ev, rl, src, cit] = await Promise.all([
|
||||
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
}),
|
||||
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/relationships", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
}),
|
||||
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
|
||||
]);
|
||||
setPeople(all.data ?? []);
|
||||
setEvents(ev.data ?? []);
|
||||
setRels(rl.data ?? []);
|
||||
setSources(src.data ?? []);
|
||||
setCitations(cit.data ?? []);
|
||||
setReady(true);
|
||||
}, [router, treeId, personId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const nameOf = useMemo(() => {
|
||||
const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"]));
|
||||
return (id: string) => m.get(id) ?? "Unknown";
|
||||
}, [people]);
|
||||
const sourceName = useMemo(() => {
|
||||
const m = new Map(sources.map((s) => [s.id, s.title]));
|
||||
return (id: string) => m.get(id) ?? "source";
|
||||
}, [sources]);
|
||||
|
||||
const others = people.filter((p) => p.id !== personId);
|
||||
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
|
||||
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
|
||||
const partners = rels.filter((r) => r.type === "partnership");
|
||||
const siblings = rels.filter((r) => r.type === "sibling");
|
||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||
const personCites = citations.filter((c) => c.person_id === personId);
|
||||
|
||||
async function addEvent(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!evType.trim()) return;
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { event_type: evType, person_id: personId, date_value: evDate || null },
|
||||
});
|
||||
if (!error) {
|
||||
setEvDate("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
async function removeEvent(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", {
|
||||
params: { path: { tree_id: treeId, event_id: id } },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
async function addRel(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!relOther) return;
|
||||
let body: RelCreate;
|
||||
if (relKind === "parent") {
|
||||
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual };
|
||||
} else if (relKind === "child") {
|
||||
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual };
|
||||
} else if (relKind === "partner") {
|
||||
body = { type: "partnership", person_from_id: personId, person_to_id: relOther };
|
||||
} else {
|
||||
body = { type: "sibling", person_from_id: personId, person_to_id: relOther };
|
||||
}
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body,
|
||||
});
|
||||
if (!error) {
|
||||
setRelOther("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
async function removeRel(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
|
||||
params: { path: { tree_id: treeId, relationship_id: id } },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
async function addCitation(target: Partial<CitationCreate>) {
|
||||
if (!citeSource) return;
|
||||
const body: CitationCreate = { source_id: citeSource, page: citePage || null, ...target };
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/citations", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body,
|
||||
});
|
||||
if (!error) {
|
||||
setCiteFor(null);
|
||||
setCiteSource("");
|
||||
setCitePage("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
async function removeCitation(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}/citations/{citation_id}", {
|
||||
params: { path: { tree_id: treeId, citation_id: id } },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
||||
|
||||
// Inline "cite" control: a badge with count, a toggle, and the picker form.
|
||||
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{cites.length > 0 && (
|
||||
<span
|
||||
className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze"
|
||||
title={cites.map((c) => sourceName(c.source_id)).join(", ")}
|
||||
>
|
||||
✓ {cites.length} sourced
|
||||
</span>
|
||||
)}
|
||||
{citeFor === key ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
addCitation(target);
|
||||
}}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
<select
|
||||
className={fieldCls}
|
||||
value={citeSource}
|
||||
onChange={(e) => setCiteSource(e.target.value)}
|
||||
>
|
||||
<option value="">— source —</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className={`${fieldCls} w-24`}
|
||||
placeholder="page"
|
||||
value={citePage}
|
||||
onChange={(e) => setCitePage(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
cite
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCiteFor(null)}
|
||||
className="text-xs text-[var(--muted)]"
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</form>
|
||||
) : sources.length === 0 ? (
|
||||
<Link href={`/trees/${treeId}/sources`} className="text-xs text-[var(--muted)] hover:underline">
|
||||
+ add a source first
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCiteFor(key);
|
||||
setCiteSource("");
|
||||
setCitePage("");
|
||||
}}
|
||||
className="text-xs text-bronze hover:underline"
|
||||
>
|
||||
+ cite
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) =>
|
||||
items.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{items.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between text-sm">
|
||||
<Link href={`/trees/${treeId}/persons/${otherId(r)}`} className="hover:underline">
|
||||
{nameOf(otherId(r))}
|
||||
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => removeRel(r.id)}
|
||||
className="text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
||||
← Back to tree
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
||||
{citeControl("p", { person_id: personId }, personCites)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Life events</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{events.map((ev) => (
|
||||
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span>
|
||||
<span className="font-medium capitalize">{ev.event_type}</span>
|
||||
{ev.date_value ? (
|
||||
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
|
||||
<button
|
||||
onClick={() => removeEvent(ev.id)}
|
||||
className="text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={addEvent} className="flex flex-wrap gap-2">
|
||||
<Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} />
|
||||
<Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} />
|
||||
<Button type="submit">Add event</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Relationships</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rels.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted)]">No relationships yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{relGroup("Parents", parents, (r) => r.person_from_id)}
|
||||
{relGroup("Children", children, (r) => r.person_to_id)}
|
||||
{relGroup("Partners", partners, (r) =>
|
||||
r.person_from_id === personId ? r.person_to_id : r.person_from_id,
|
||||
)}
|
||||
{relGroup("Siblings", siblings, (r) =>
|
||||
r.person_from_id === personId ? r.person_to_id : r.person_from_id,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{others.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted)]">Add more people to the tree to link them.</p>
|
||||
) : (
|
||||
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-[var(--muted)]">Add</span>
|
||||
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
|
||||
<option value="parent">parent</option>
|
||||
<option value="child">child</option>
|
||||
<option value="partner">partner</option>
|
||||
<option value="sibling">sibling</option>
|
||||
</select>
|
||||
<select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}>
|
||||
<option value="">— person —</option>
|
||||
{others.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.primary_name ?? "Unnamed"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(relKind === "parent" || relKind === "child") && (
|
||||
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
||||
{QUALIFIERS.map((q) => (
|
||||
<option key={q} value={q}>
|
||||
{q}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button type="submit">Link</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type Source = components["schemas"]["SourceRead"];
|
||||
|
||||
export default function SourcesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const treeId = params.id;
|
||||
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [repository, setRepository] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/sources", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
});
|
||||
if (response.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setSources(data ?? []);
|
||||
setReady(true);
|
||||
}, [router, treeId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function add(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/sources", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { title, repository: repository || null, url: url || null },
|
||||
});
|
||||
if (!error) {
|
||||
setTitle("");
|
||||
setRepository("");
|
||||
setUrl("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}/sources/{source_id}", {
|
||||
params: { path: { tree_id: treeId, source_id: id } },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
||||
← Back to tree
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Sources</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">New source</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={add} className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
className="w-56"
|
||||
placeholder="Title (e.g. 1880 US Census)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="w-40"
|
||||
placeholder="Repository"
|
||||
value={repository}
|
||||
onChange={(e) => setRepository(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<Button type="submit">Add source</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<p className="text-[var(--muted)]">No sources yet — add one above, then cite it on facts.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{sources.map((s) => (
|
||||
<li key={s.id}>
|
||||
<Card>
|
||||
<CardContent className="flex items-start justify-between gap-3 p-4">
|
||||
<div>
|
||||
<div className="font-medium">{s.title}</div>
|
||||
<div className="text-sm text-[var(--muted)]">
|
||||
{[s.repository, s.url].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => remove(s.id)}
|
||||
className="text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type Tree = components["schemas"]["TreeRead"];
|
||||
|
||||
export default function TreesPage() {
|
||||
const router = useRouter();
|
||||
const [trees, setTrees] = useState<Tree[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data, response } = await api.GET("/api/v1/trees");
|
||||
if (response.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setTrees(data ?? []);
|
||||
setReady(true);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function createTree(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const { error } = await api.POST("/api/v1/trees", { body: { name } });
|
||||
if (!error) {
|
||||
setName("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.POST("/api/v1/auth/logout");
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Your trees</h1>
|
||||
<Button variant="ghost" size="sm" onClick={logout}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">New tree</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={createTree} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Family name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button type="submit">Create</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{trees.length === 0 ? (
|
||||
<p className="text-[var(--muted)]">No trees yet — create your first one above.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{trees.map((tree) => (
|
||||
<li key={tree.id}>
|
||||
<Link href={`/trees/${tree.id}`}>
|
||||
<Card className="transition-colors hover:border-bronze/50">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<span className="font-medium">{tree.name}</span>
|
||||
<span className="text-xs uppercase tracking-wide text-bronze">
|
||||
{tree.visibility}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-bronze text-paper shadow-sm hover:bg-bronze-deep hover:shadow",
|
||||
outline:
|
||||
"border border-[var(--border)] bg-[var(--surface)] hover:border-bronze hover:text-bronze",
|
||||
ghost: "text-[var(--foreground)] hover:bg-bronze/10",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 text-sm",
|
||||
sm: "h-9 px-3 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-[0_1px_2px_rgba(26,26,23,0.04),0_8px_24px_-12px_rgba(26,26,23,0.10)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("font-serif text-lg font-semibold", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm placeholder:text-[var(--muted)] transition-colors focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40 disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label ref={ref} className={cn("text-sm font-medium", className)} {...props} />
|
||||
));
|
||||
Label.displayName = "Label";
|
||||
@@ -0,0 +1,10 @@
|
||||
import createClient from "openapi-fetch";
|
||||
|
||||
import type { paths } from "./schema";
|
||||
|
||||
// Same-origin in production (Caddy proxies /api/* to the backend). Override with
|
||||
// NEXT_PUBLIC_API_BASE_URL for split local dev. credentials:"include" sends the
|
||||
// HttpOnly session cookie the backend issues on login.
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
export const api = createClient<paths>({ baseUrl, credentials: "include" });
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Standalone output keeps the production container small.
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "provenance-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"openapi-fetch": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"lucide-react": "^0.469.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
"openapi-typescript": "^7.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" role="img" aria-label="Provenance icon">
|
||||
<title>Provenance icon</title>
|
||||
<rect x="0" y="0" width="48" height="48" rx="8.64" fill="#A06A42"/>
|
||||
<g transform="translate(16.32 14.26)"><path d="M12.47 5.77Q12.47 3.37 11.35 2.34Q10.23 1.31 7.58 1.31L6.16 1.31L6.16 10.54L7.67 10.54Q10.13 10.54 11.29 9.42Q12.47 8.3 12.47 5.77ZM6.16 11.84L6.16 18.33L9.26 18.72L9.26 19.49L1.05 19.49L1.05 18.72L3.36 18.33L3.36 1.15L0.86 0.77L0.86 0.0L8.21 0.0Q15.36 0.0 15.36 5.74Q15.36 8.73 13.55 10.29Q11.74 11.84 8.36 11.84L6.16 11.84Z" fill="#F7F3EC"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="450.31" height="128" viewBox="0 0 450.31 128" role="img" aria-label="Provenance">
|
||||
<title>Provenance</title>
|
||||
<style>.ink{fill:#1A1A17}.ink-s{stroke:#1A1A17}.br{fill:#A06A42}.br-s{stroke:#A06A42}.muted{fill:#6B6862}
|
||||
@media (prefers-color-scheme:dark){.ink{fill:#F2EEE6}.ink-s{stroke:#F2EEE6}.muted{fill:#9A968E}}</style>
|
||||
<circle cx="64" cy="64.0" r="30" class="br-s" fill="none" stroke-width="2"/>
|
||||
<line x1="64" y1="38.0" x2="64" y2="30.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="64" y1="90.0" x2="64" y2="98.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="90" y1="64.0" x2="98" y2="64.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="38" y1="64.0" x2="30" y2="64.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="64" y1="64.0" x2="85.21" y2="42.79" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||
<line x1="64" y1="64.0" x2="42.79" y2="42.79" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||
<line x1="64" y1="64.0" x2="85.21" y2="85.21" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||
<line x1="64" y1="64.0" x2="42.79" y2="85.21" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||
<circle cx="85.21" cy="42.79" r="3.15" class="ink"/>
|
||||
<circle cx="42.79" cy="42.79" r="3.15" class="ink"/>
|
||||
<circle cx="85.21" cy="85.21" r="3.15" class="ink"/>
|
||||
<circle cx="42.79" cy="85.21" r="3.15" class="ink"/>
|
||||
<circle cx="64" cy="64.0" r="4.5" class="ink"/>
|
||||
<g transform="translate(126 42.73)"><path d="M26.81 12.41Q26.81 7.25 24.4 5.04Q22.0 2.82 16.31 2.82L13.25 2.82L13.25 22.66L16.5 22.66Q21.78 22.66 24.29 20.26Q26.81 17.85 26.81 12.41ZM13.25 25.47L13.25 39.41L19.91 40.25L19.91 41.91L2.25 41.91L2.25 40.25L7.22 39.41L7.22 2.47L1.84 1.66L1.84 0.0L17.66 0.0Q33.03 0.0 33.03 12.35Q33.03 18.78 29.14 22.13Q25.25 25.47 17.97 25.47L13.25 25.47ZM56.34 11.75L56.34 19.69L55.0 19.69L53.18 16.25Q51.62 16.25 49.48 16.68Q47.34 17.1 45.78 17.78L45.78 39.72L50.81 40.5L50.81 41.91L36.87 41.91L36.87 40.5L40.59 39.72L40.59 14.72L36.87 13.94L36.87 12.53L45.43 12.53L45.72 16.19Q47.59 14.63 50.79 13.19Q54.0 11.75 55.87 11.75L56.34 11.75ZM86.47 27.07Q86.47 42.54 72.72 42.54Q66.1 42.54 62.72 38.57Q59.35 34.6 59.35 27.07Q59.35 19.63 62.72 15.69Q66.1 11.75 72.97 11.75Q79.66 11.75 83.06 15.61Q86.47 19.47 86.47 27.07ZM80.85 27.07Q80.85 20.32 78.88 17.29Q76.91 14.25 72.72 14.25Q68.63 14.25 66.8 17.16Q64.97 20.07 64.97 27.07Q64.97 34.16 66.83 37.12Q68.69 40.07 72.72 40.07Q76.85 40.07 78.85 37.01Q80.85 33.94 80.85 27.07ZM106.32 42.54L104.0 42.54L91.91 14.72L88.91 13.94L88.91 12.53L102.6 12.53L102.6 13.94L97.94 14.78L106.5 35.07L114.69 14.72L110.04 13.94L110.04 12.53L120.91 12.53L120.91 13.94L118.1 14.6L106.32 42.54ZM129.04 27.13L129.04 27.69Q129.04 32.0 129.99 34.39Q130.94 36.78 132.92 38.03Q134.91 39.28 138.13 39.28Q139.82 39.28 142.13 39.0Q144.44 38.72 145.94 38.38L145.94 40.13Q144.44 41.1 141.86 41.82Q139.29 42.54 136.6 42.54Q129.75 42.54 126.58 38.85Q123.41 35.16 123.41 27.0Q123.41 19.32 126.63 15.54Q129.85 11.75 135.82 11.75Q147.1 11.75 147.1 24.57L147.1 27.13L129.04 27.13ZM135.82 14.25Q132.57 14.25 130.83 16.88Q129.1 19.5 129.1 24.63L141.66 24.63Q141.66 19.03 140.22 16.64Q138.79 14.25 135.82 14.25ZM159.44 14.91Q161.84 13.53 164.56 12.64Q167.28 11.75 169.09 11.75Q172.9 11.75 174.84 13.97Q176.78 16.19 176.78 20.41L176.78 39.72L180.34 40.5L180.34 41.91L167.69 41.91L167.69 40.5L171.59 39.72L171.59 20.97Q171.59 18.38 170.32 16.9Q169.06 15.41 166.4 15.41Q163.59 15.41 159.5 16.32L159.5 39.72L163.47 40.5L163.47 41.91L150.78 41.91L150.78 40.5L154.31 39.72L154.31 14.72L150.78 13.94L150.78 12.53L159.15 12.53L159.44 14.91ZM195.84 11.88Q200.65 11.88 202.92 13.85Q205.19 15.82 205.19 19.88L205.19 39.72L208.84 40.5L208.84 41.91L200.78 41.91L200.19 38.97Q196.62 42.54 191.09 42.54Q183.56 42.54 183.56 33.78Q183.56 30.85 184.7 28.93Q185.84 27.0 188.34 25.99Q190.84 24.97 195.59 24.88L200.0 24.75L200.0 20.16Q200.0 17.13 198.89 15.69Q197.78 14.25 195.47 14.25Q192.34 14.25 189.75 15.72L188.69 19.38L186.94 19.38L186.94 12.97Q192.0 11.88 195.84 11.88ZM200.0 26.94L195.9 27.07Q191.72 27.22 190.23 28.69Q188.75 30.16 188.75 33.6Q188.75 39.1 193.22 39.1Q195.34 39.1 196.89 38.62Q198.44 38.13 200.0 37.38L200.0 26.94ZM219.85 14.91Q222.25 13.53 224.97 12.64Q227.69 11.75 229.5 11.75Q233.31 11.75 235.25 13.97Q237.19 16.19 237.19 20.41L237.19 39.72L240.75 40.5L240.75 41.91L228.1 41.91L228.1 40.5L232.0 39.72L232.0 20.97Q232.0 18.38 230.73 16.9Q229.47 15.41 226.81 15.41Q224.0 15.41 219.91 16.32L219.91 39.72L223.88 40.5L223.88 41.91L211.19 41.91L211.19 40.5L214.72 39.72L214.72 14.72L211.19 13.94L211.19 12.53L219.56 12.53L219.85 14.91ZM268.16 40.13Q266.63 41.25 263.94 41.9Q261.25 42.54 258.44 42.54Q244.16 42.54 244.16 27.0Q244.16 19.66 247.8 15.71Q251.44 11.75 258.22 11.75Q262.44 11.75 267.44 12.72L267.44 20.91L265.72 20.91L264.38 15.72Q261.78 14.25 258.16 14.25Q249.78 14.25 249.78 27.0Q249.78 33.63 252.33 36.46Q254.88 39.28 260.22 39.28Q264.78 39.28 268.16 38.25L268.16 40.13ZM278.25 27.13L278.25 27.69Q278.25 32.0 279.2 34.39Q280.16 36.78 282.13 38.03Q284.12 39.28 287.35 39.28Q289.04 39.28 291.35 39.0Q293.66 38.72 295.16 38.38L295.16 40.13Q293.66 41.1 291.07 41.82Q288.5 42.54 285.81 42.54Q278.97 42.54 275.8 38.85Q272.62 35.16 272.62 27.0Q272.62 19.32 275.85 15.54Q279.06 11.75 285.04 11.75Q296.31 11.75 296.31 24.57L296.31 27.13L278.25 27.13ZM285.04 14.25Q281.79 14.25 280.05 16.88Q278.31 19.5 278.31 24.63L290.88 24.63Q290.88 19.03 289.44 16.64Q288.0 14.25 285.04 14.25Z" class="ink"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Provenance mark">
|
||||
<title>Provenance mark</title>
|
||||
<style>.ink{fill:#1A1A17}.ink-s{stroke:#1A1A17}.br{fill:#A06A42}.br-s{stroke:#A06A42}.muted{fill:#6B6862}
|
||||
@media (prefers-color-scheme:dark){.ink{fill:#F2EEE6}.ink-s{stroke:#F2EEE6}.muted{fill:#9A968E}}</style>
|
||||
<circle cx="48.0" cy="48.0" r="34" class="br-s" fill="none" stroke-width="2.2"/>
|
||||
<line x1="48.0" y1="18.0" x2="48.0" y2="10.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<line x1="48.0" y1="78.0" x2="48.0" y2="86.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<line x1="78.0" y1="48.0" x2="86.0" y2="48.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<line x1="18.0" y1="48.0" x2="10.0" y2="48.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<line x1="48.0" y1="48.0" x2="72.04" y2="23.96" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||
<line x1="48.0" y1="48.0" x2="23.96" y2="23.96" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||
<line x1="48.0" y1="48.0" x2="72.04" y2="72.04" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||
<line x1="48.0" y1="48.0" x2="23.96" y2="72.04" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||
<circle cx="72.04" cy="23.96" r="3.57" class="ink"/>
|
||||
<circle cx="23.96" cy="23.96" r="3.57" class="ink"/>
|
||||
<circle cx="72.04" cy="72.04" r="3.57" class="ink"/>
|
||||
<circle cx="23.96" cy="72.04" r="3.57" class="ink"/>
|
||||
<circle cx="48.0" cy="48.0" r="5.1" class="ink"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||