Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34d30e3134 | |||
| 049545fcc8 | |||
| 3a14fcc4ca | |||
| fc4cb0273e | |||
| 83f83ab641 | |||
| 064bb6ea65 | |||
| fbb9d0195c | |||
| 1f25eb2f21 | |||
| d6e2df4a61 | |||
| a799d101b5 | |||
| 0b9d72c878 | |||
| 2d0635e710 |
@@ -10,6 +10,8 @@ from app.core.db import get_session
|
|||||||
from app.integrations.mailer.base import Mailer
|
from app.integrations.mailer.base import Mailer
|
||||||
from app.integrations.mailer.console import ConsoleMailer
|
from app.integrations.mailer.console import ConsoleMailer
|
||||||
from app.integrations.mailer.smtp import SMTPMailer
|
from app.integrations.mailer.smtp import SMTPMailer
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.integrations.objectstore.s3 import S3ObjectStore
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import auth_service
|
from app.services import auth_service
|
||||||
|
|
||||||
@@ -46,3 +48,10 @@ def get_mailer() -> Mailer:
|
|||||||
|
|
||||||
|
|
||||||
MailerDep = Annotated[Mailer, Depends(get_mailer)]
|
MailerDep = Annotated[Mailer, Depends(get_mailer)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_objectstore() -> ObjectStore:
|
||||||
|
return S3ObjectStore(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
ObjectStoreDep = Annotated[ObjectStore, Depends(get_objectstore)]
|
||||||
|
|||||||
@@ -2,10 +2,25 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, persons, trees, users
|
from app.api.v1 import (
|
||||||
|
auth,
|
||||||
|
citations,
|
||||||
|
events,
|
||||||
|
media,
|
||||||
|
persons,
|
||||||
|
relationships,
|
||||||
|
sources,
|
||||||
|
trees,
|
||||||
|
users,
|
||||||
|
)
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(trees.router)
|
api_router.include_router(trees.router)
|
||||||
api_router.include_router(persons.router)
|
api_router.include_router(persons.router)
|
||||||
|
api_router.include_router(events.router)
|
||||||
|
api_router.include_router(relationships.router)
|
||||||
|
api_router.include_router(sources.router)
|
||||||
|
api_router.include_router(citations.router)
|
||||||
|
api_router.include_router(media.router)
|
||||||
|
|||||||
@@ -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,62 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, UploadFile, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
|
from app.schemas.media import MediaRead
|
||||||
|
from app.services import media_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
def _with_url(media, url: str) -> MediaRead:
|
||||||
|
out = MediaRead.model_validate(media)
|
||||||
|
out.url = url
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_media(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
title: str | None = Form(None),
|
||||||
|
person_id: uuid.UUID | None = Form(None),
|
||||||
|
event_id: uuid.UUID | None = Form(None),
|
||||||
|
source_id: uuid.UUID | None = Form(None),
|
||||||
|
) -> MediaRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
data = await file.read()
|
||||||
|
media = await media_service.upload_media(
|
||||||
|
session,
|
||||||
|
store,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
data=data,
|
||||||
|
filename=file.filename or "upload",
|
||||||
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
title=title,
|
||||||
|
person_id=person_id,
|
||||||
|
event_id=event_id,
|
||||||
|
source_id=source_id,
|
||||||
|
)
|
||||||
|
return _with_url(media, await store.presigned_get_url(key=media.storage_key))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/media", response_model=list[MediaRead])
|
||||||
|
async def list_media(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, store: ObjectStoreDep
|
||||||
|
) -> list[MediaRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [_with_url(m, await store.presigned_get_url(key=m.storage_key)) for m in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_media(
|
||||||
|
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await media_service.delete_media(session, actor=current, tree=tree, media_id=media_id)
|
||||||
@@ -41,3 +41,14 @@ async def list_persons(
|
|||||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
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)
|
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
||||||
return [PersonRead.model_validate(p) for p in persons]
|
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)
|
||||||
@@ -35,6 +35,18 @@ class Settings(BaseSettings):
|
|||||||
# Base URL used to build links in outbound email.
|
# Base URL used to build links in outbound email.
|
||||||
app_base_url: str = "http://localhost"
|
app_base_url: str = "http://localhost"
|
||||||
|
|
||||||
|
# --- Object storage (S3-compatible / MinIO) ---
|
||||||
|
s3_endpoint_url: str = "http://minio:9000"
|
||||||
|
s3_bucket: str = "provenance"
|
||||||
|
s3_access_key: str = "provenance"
|
||||||
|
s3_secret_key: str = "change-me-too"
|
||||||
|
s3_region: str = "us-east-1"
|
||||||
|
s3_presign_ttl: int = 3600 # seconds
|
||||||
|
|
||||||
|
# --- Worker ---
|
||||||
|
purge_interval_seconds: int = 3600 # how often to run the soft-delete purge
|
||||||
|
purge_after_days: int = 30 # soft-deleted rows older than this are purged
|
||||||
|
|
||||||
# --- Email (SMTP) ---
|
# --- Email (SMTP) ---
|
||||||
mailer: str = Field(default="console", description="console | smtp")
|
mailer: str = Field(default="console", description="console | smtp")
|
||||||
smtp_host: str | None = None
|
smtp_host: str | None = None
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""ObjectStore interface — pluggable binary storage behind the service layer.
|
||||||
|
|
||||||
|
Implementations are S3-compatible (MinIO for self-host, any S3 otherwise).
|
||||||
|
Methods are async wrappers so the service layer stays non-blocking even though
|
||||||
|
the underlying SDK (boto3) is synchronous.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def ensure_bucket(self) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_object(self, *, key: str) -> None: ...
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""S3-compatible ObjectStore (boto3), suitable for MinIO or any S3 provider.
|
||||||
|
|
||||||
|
boto3 is synchronous; each call is dispatched to a thread so request handlers
|
||||||
|
and the worker stay async."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
|
||||||
|
|
||||||
|
class S3ObjectStore(ObjectStore):
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.bucket = settings.s3_bucket
|
||||||
|
self.presign_ttl = settings.s3_presign_ttl
|
||||||
|
self._client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.s3_endpoint_url,
|
||||||
|
aws_access_key_id=settings.s3_access_key,
|
||||||
|
aws_secret_access_key=settings.s3_secret_key,
|
||||||
|
region_name=settings.s3_region,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_bucket_sync(self) -> None:
|
||||||
|
try:
|
||||||
|
self._client.head_bucket(Bucket=self.bucket)
|
||||||
|
except ClientError:
|
||||||
|
self._client.create_bucket(Bucket=self.bucket)
|
||||||
|
|
||||||
|
async def ensure_bucket(self) -> None:
|
||||||
|
await asyncio.to_thread(self._ensure_bucket_sync)
|
||||||
|
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self._client.put_object,
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=key,
|
||||||
|
Body=data,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self._client.generate_presigned_url,
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": self.bucket, "Key": key},
|
||||||
|
ExpiresIn=self.presign_ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_object(self, *, key: str) -> None:
|
||||||
|
await asyncio.to_thread(self._client.delete_object, Bucket=self.bucket, Key=key)
|
||||||
@@ -5,6 +5,7 @@ from app.models.audit import AuditEntry
|
|||||||
from app.models.auth import Session, UserToken
|
from app.models.auth import Session, UserToken
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
|
from app.models.media import Media
|
||||||
from app.models.person import Name, Person
|
from app.models.person import Name, Person
|
||||||
from app.models.place import Place, PlaceName
|
from app.models.place import Place, PlaceName
|
||||||
from app.models.relationship import Relationship
|
from app.models.relationship import Relationship
|
||||||
@@ -28,4 +29,5 @@ __all__ = [
|
|||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"Session",
|
"Session",
|
||||||
"UserToken",
|
"UserToken",
|
||||||
|
"Media",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Media — a binary asset (image, scan, PDF, audio) in object storage. The row
|
||||||
|
holds metadata + checksum + the storage key; the bytes live in the ObjectStore.
|
||||||
|
Optionally attached to a single fact (person, event, or source) for now."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Media(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "media"
|
||||||
|
|
||||||
|
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
storage_key: Mapped[str] = mapped_column(String(512), unique=True)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(512))
|
||||||
|
content_type: Mapped[str] = mapped_column(String(128))
|
||||||
|
byte_size: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
checksum_sha256: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
title: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
|
||||||
|
# Optional single attachment target.
|
||||||
|
person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("persons.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
event_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("events.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("sources.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
@@ -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,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class MediaRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
byte_size: int
|
||||||
|
checksum_sha256: str
|
||||||
|
title: str | None
|
||||||
|
person_id: uuid.UUID | None
|
||||||
|
event_id: uuid.UUID | None
|
||||||
|
source_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
# Presigned download URL, filled in by the router from the ObjectStore.
|
||||||
|
url: str | None = None
|
||||||
@@ -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,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,107 @@
|
|||||||
|
"""Media service. Bytes go to the ObjectStore; a metadata row goes to the DB.
|
||||||
|
Writes require editor rights; reads go through the privacy engine."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.models.media import Media
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
session: AsyncSession,
|
||||||
|
store: ObjectStore,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
data: bytes,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
title: str | None = None,
|
||||||
|
person_id: uuid.UUID | None = None,
|
||||||
|
event_id: uuid.UUID | None = None,
|
||||||
|
source_id: uuid.UUID | None = None,
|
||||||
|
) -> Media:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
media_id = uuid.uuid4()
|
||||||
|
key = f"{tree.id}/{media_id}/{filename}"
|
||||||
|
await store.ensure_bucket()
|
||||||
|
await store.put_object(key=key, data=data, content_type=content_type)
|
||||||
|
|
||||||
|
media = Media(
|
||||||
|
id=media_id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
uploader_id=actor.id,
|
||||||
|
storage_key=key,
|
||||||
|
original_filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
byte_size=len(data),
|
||||||
|
checksum_sha256=hashlib.sha256(data).hexdigest(),
|
||||||
|
title=title,
|
||||||
|
person_id=person_id,
|
||||||
|
event_id=event_id,
|
||||||
|
source_id=source_id,
|
||||||
|
)
|
||||||
|
session.add(media)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Media",
|
||||||
|
entity_id=media.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"filename": filename, "bytes": len(data)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(media)
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Media)
|
||||||
|
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
||||||
|
.order_by(Media.created_at.desc())
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_media(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
media = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(
|
||||||
|
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if media is None:
|
||||||
|
raise NotFound("media not found")
|
||||||
|
# Soft delete the row; the object is removed by the worker's purge job.
|
||||||
|
media.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Media",
|
||||||
|
entity_id=media.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -14,7 +14,7 @@ from app.models.tree import Tree
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import privacy
|
from app.services import privacy
|
||||||
from app.services.audit import record_audit
|
from app.services.audit import record_audit
|
||||||
from app.services.exceptions import Forbidden
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
from app.services.privacy import Visibility
|
from app.services.privacy import Visibility
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +86,32 @@ async def create_person(
|
|||||||
return 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(
|
async def list_persons(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
) -> list[Person]:
|
) -> list[Person]:
|
||||||
|
|||||||
@@ -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,103 @@
|
|||||||
|
"""Background worker. Same image as the backend, run in worker mode
|
||||||
|
(`python -m app.worker`). First job: the scheduled soft-delete purge — hard-
|
||||||
|
delete rows whose ``deleted_at`` is older than the recovery window, and remove
|
||||||
|
their objects from storage. More jobs (media processing, scraping, hints) and a
|
||||||
|
proper queue arrive in later phases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import get_sessionmaker
|
||||||
|
from app.integrations.objectstore.s3 import S3ObjectStore
|
||||||
|
from app.models import (
|
||||||
|
Citation,
|
||||||
|
Event,
|
||||||
|
Media,
|
||||||
|
Name,
|
||||||
|
Person,
|
||||||
|
Place,
|
||||||
|
PlaceName,
|
||||||
|
Relationship,
|
||||||
|
Source,
|
||||||
|
Tree,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("provenance.worker")
|
||||||
|
|
||||||
|
# Child -> parent so foreign keys are satisfied as rows are removed.
|
||||||
|
_PURGE_ORDER = [Citation, Name, Event, Relationship, PlaceName, Place, Source, Person, Tree, User]
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_media(sessionmaker, store, cutoff: datetime) -> None:
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(Media.deleted_at.is_not(None), Media.deleted_at < cutoff)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for media in rows:
|
||||||
|
try:
|
||||||
|
await store.delete_object(key=media.storage_key)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("object delete failed for %s: %s", media.storage_key, exc)
|
||||||
|
await session.delete(media)
|
||||||
|
await session.commit()
|
||||||
|
if rows:
|
||||||
|
logger.info("purged %d media", len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_table(sessionmaker, model, cutoff: datetime) -> None:
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
try:
|
||||||
|
res = await session.execute(
|
||||||
|
delete(model).where(model.deleted_at.is_not(None), model.deleted_at < cutoff)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
if res.rowcount:
|
||||||
|
logger.info("purged %d %s", res.rowcount, model.__tablename__)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await session.rollback()
|
||||||
|
logger.warning("purge %s failed: %s", model.__tablename__, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_once(sessionmaker, store) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(days=settings.purge_after_days)
|
||||||
|
await _purge_media(sessionmaker, store, cutoff)
|
||||||
|
for model in _PURGE_ORDER:
|
||||||
|
await _purge_table(sessionmaker, model, cutoff)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s", stream=sys.stdout
|
||||||
|
)
|
||||||
|
settings = get_settings()
|
||||||
|
store = S3ObjectStore(settings)
|
||||||
|
try:
|
||||||
|
await store.ensure_bucket()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("ensure_bucket failed: %s", exc)
|
||||||
|
sessionmaker = get_sessionmaker()
|
||||||
|
logger.info(
|
||||||
|
"worker started; purge every %ds (recovery window %dd)",
|
||||||
|
settings.purge_interval_seconds,
|
||||||
|
settings.purge_after_days,
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await purge_once(sessionmaker, store)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("purge cycle error: %s", exc)
|
||||||
|
await asyncio.sleep(settings.purge_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""media
|
||||||
|
|
||||||
|
Revision ID: 7fc7024ef432
|
||||||
|
Revises: 1f6e54f6406a
|
||||||
|
Create Date: 2026-06-06 21:44:03.204170
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7fc7024ef432'
|
||||||
|
down_revision: str | None = '1f6e54f6406a'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('media',
|
||||||
|
sa.Column('uploader_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('storage_key', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('original_filename', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('content_type', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('byte_size', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('person_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('event_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('source_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_media_event_id_events'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_media_person_id_persons'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_media_source_id_sources'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_media_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['uploader_id'], ['users.id'], name=op.f('fk_media_uploader_id_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_media')),
|
||||||
|
sa.UniqueConstraint('storage_key', name=op.f('uq_media_storage_key'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_media_checksum_sha256'), 'media', ['checksum_sha256'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_event_id'), 'media', ['event_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_person_id'), 'media', ['person_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_source_id'), 'media', ['source_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_tree_id'), 'media', ['tree_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_uploader_id'), 'media', ['uploader_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_media_uploader_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_tree_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_source_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_person_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_event_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_checksum_sha256'), table_name='media')
|
||||||
|
op.drop_table('media')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -12,6 +12,8 @@ dependencies = [
|
|||||||
"asyncpg>=0.30",
|
"asyncpg>=0.30",
|
||||||
"alembic>=1.14",
|
"alembic>=1.14",
|
||||||
"argon2-cffi>=23.1",
|
"argon2-cffi>=23.1",
|
||||||
|
"boto3>=1.35",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -36,6 +38,10 @@ extend-exclude = ["migrations/versions"]
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B"]
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
|
# FastAPI uses these as call-expressions in argument defaults by design.
|
||||||
|
extend-immutable-calls = ["fastapi.File", "fastapi.Form", "fastapi.Depends", "fastapi.Query", "fastapi.Header"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
import app.models # noqa: F401 — register all models on Base.metadata
|
import app.models # noqa: F401 — register all models on Base.metadata
|
||||||
from app.api.deps import get_mailer
|
from app.api.deps import get_mailer, get_objectstore
|
||||||
from app.core.db import get_session
|
from app.core.db import get_session
|
||||||
from app.integrations.mailer.base import Mailer
|
from app.integrations.mailer.base import Mailer
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
|
|
||||||
@@ -35,7 +36,25 @@ class CapturingMailer(Mailer):
|
|||||||
self.resets.append((to, link))
|
self.resets.append((to, link))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeObjectStore(ObjectStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.objects: dict[str, tuple[bytes, str]] = {}
|
||||||
|
|
||||||
|
async def ensure_bucket(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
self.objects[key] = (data, content_type)
|
||||||
|
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str:
|
||||||
|
return f"https://objects.test/{key}"
|
||||||
|
|
||||||
|
async def delete_object(self, *, key: str) -> None:
|
||||||
|
self.objects.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
_mailer = CapturingMailer()
|
_mailer = CapturingMailer()
|
||||||
|
_store = FakeObjectStore()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -61,8 +80,10 @@ async def client():
|
|||||||
|
|
||||||
_mailer.verifications.clear()
|
_mailer.verifications.clear()
|
||||||
_mailer.resets.clear()
|
_mailer.resets.clear()
|
||||||
|
_store.objects.clear()
|
||||||
app.dependency_overrides[get_session] = _override_session
|
app.dependency_overrides[get_session] = _override_session
|
||||||
app.dependency_overrides[get_mailer] = lambda: _mailer
|
app.dependency_overrides[get_mailer] = lambda: _mailer
|
||||||
|
app.dependency_overrides[get_objectstore] = lambda: _store
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||||
|
|||||||
@@ -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,45 @@
|
|||||||
|
"""Media upload/list/delete through the API (object store faked in conftest)."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "M"}, headers=h)).json()["id"]
|
||||||
|
return h, tree_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_upload_list_delete(client):
|
||||||
|
h, tree_id = await _tree(client, "media1@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/media",
|
||||||
|
files={"file": ("scan.txt", b"hello world", "text/plain")},
|
||||||
|
data={"title": "A scan"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["original_filename"] == "scan.txt"
|
||||||
|
assert body["byte_size"] == 11
|
||||||
|
assert body["url"].startswith("https://objects.test/")
|
||||||
|
media_id = body["id"]
|
||||||
|
|
||||||
|
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_member_cannot_upload(client):
|
||||||
|
h, tree_id = await _tree(client, "media2@example.com")
|
||||||
|
other = auth(await register(client, "media-intruder@example.com"))
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/media",
|
||||||
|
files={"file": ("x.txt", b"x", "text/plain")},
|
||||||
|
headers=other,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -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
|
||||||
Generated
+92
@@ -125,6 +125,34 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "boto3"
|
||||||
|
version = "1.43.24"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "s3transfer" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/8f/94dfa39ec618ecb2fe5b5b79428c95100e3ae3c1aa5083c283dd3cfb5ecd/boto3-1.43.24.tar.gz", hash = "sha256:ba5afa266bf7265e0c1a454fcfd48bffe5939cb16ed223bebc669c3dc8ee0bc8", size = 113154, upload-time = "2026-06-05T19:30:01.635Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/b7/e66c9b37b96153aa371fe48d24194151293f6577dd3eaa1fc146c281456d/boto3-1.43.24-py3-none-any.whl", hash = "sha256:b18ef745274ef548a9660d733d985d4a971b16bd8a6af88165ea9d0e40913b86", size = 140536, upload-time = "2026-06-05T19:29:58.968Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "botocore"
|
||||||
|
version = "1.43.24"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/78/67/55d0611b341482bc9649d16df765f849a1862184ac3709356decf632279f/botocore-1.43.24.tar.gz", hash = "sha256:0c02f2b40e99419d496ece0ea2dcdedb5c45998c16fd1674276c7dbb30767a16", size = 15471690, upload-time = "2026-06-05T19:29:33.731Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/b7/360b5afe74c4d7cff871ea6e8f335e2e11de2945c9deb1eea6438f49faa2/botocore-1.43.24-py3-none-any.whl", hash = "sha256:42903b4bfafd8f15a735ed940473f28e4ba21b2ea67a9b9aaa11dfa7fcb19fd5", size = 15155182, upload-time = "2026-06-05T19:29:29.457Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.5.20"
|
version = "2026.5.20"
|
||||||
@@ -357,6 +385,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jmespath"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mako"
|
name = "mako"
|
||||||
version = "1.3.12"
|
version = "1.3.12"
|
||||||
@@ -447,9 +484,11 @@ dependencies = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
{ name = "boto3" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
@@ -467,9 +506,11 @@ requires-dist = [
|
|||||||
{ name = "alembic", specifier = ">=1.14" },
|
{ name = "alembic", specifier = ">=1.14" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||||
{ name = "asyncpg", specifier = ">=0.30" },
|
{ name = "asyncpg", specifier = ">=0.30" },
|
||||||
|
{ name = "boto3", specifier = ">=1.35" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "pydantic", specifier = ">=2.9" },
|
{ name = "pydantic", specifier = ">=2.9" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.5" },
|
{ name = "pydantic-settings", specifier = ">=2.5" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.12" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
|
||||||
]
|
]
|
||||||
@@ -613,6 +654,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -622,6 +675,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.32"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
@@ -683,6 +745,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "s3transfer"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.50"
|
version = "2.0.50"
|
||||||
@@ -755,6 +838,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.49.0"
|
version = "0.49.0"
|
||||||
|
|||||||
+3
-3
@@ -31,9 +31,9 @@ S3_REGION=us-east-1
|
|||||||
PROVENANCE_SITE_ADDRESS=:80
|
PROVENANCE_SITE_ADDRESS=:80
|
||||||
|
|
||||||
# --- Deploy-host services (optional, selected via COMPOSE_PROFILES) ---
|
# --- Deploy-host services (optional, selected via COMPOSE_PROFILES) ---
|
||||||
# 'tunnel' -> cloudflared connector (needs CLOUDFLARE_TUNNEL_TOKEN; public hostname -> http://caddy:80)
|
# 'tunnel' -> cloudflared connector (needs CLOUDFLARE_TUNNEL_TOKEN; public hostname -> http://caddy:80)
|
||||||
# 'watchtower' -> auto-pull updated backend/frontend images every 2 min (needs `docker login git.jpaul.io` on the host)
|
# Auto-deploy is handled by the host's global Watchtower (watches the
|
||||||
# Combine with commas. On the lab host: COMPOSE_PROFILES=tunnel,watchtower
|
# watchtower-enabled backend/frontend labels) — no profile needed here.
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=
|
CLOUDFLARE_TUNNEL_TOKEN=
|
||||||
COMPOSE_PROFILES=
|
COMPOSE_PROFILES=
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ services:
|
|||||||
context: ../backend
|
context: ../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ../frontend
|
context: ../frontend
|
||||||
|
|||||||
+34
-14
@@ -47,9 +47,16 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_ENV: ${APP_ENV:-development}
|
APP_ENV: ${APP_ENV:-development}
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-provenance}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
@@ -62,6 +69,28 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Background worker — same image as the backend, run in worker mode.
|
||||||
|
# First job: the scheduled soft-delete purge (and media object cleanup).
|
||||||
|
worker:
|
||||||
|
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
|
||||||
|
command: ["uv", "run", "--no-dev", "python", "-m", "app.worker"]
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV:-development}
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-provenance}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main}
|
image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main}
|
||||||
labels:
|
labels:
|
||||||
@@ -108,20 +137,11 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- tunnel
|
- tunnel
|
||||||
|
|
||||||
# Auto-deploy: watch the label-enabled app containers (backend, frontend),
|
# Auto-deploy is handled by the host's global Watchtower (a single
|
||||||
# poll the registry every 2 minutes, and recreate on a new :test-main digest.
|
# nickfedor/watchtower instance watches every container labelled
|
||||||
# Scoped by label so it never touches Postgres/MinIO/Caddy. Registry creds come
|
# `com.centurylinklabs.watchtower.enable=true` across all stacks). The backend
|
||||||
# from the host docker config (the `docker login git.jpaul.io` on the host).
|
# and frontend carry that label above, so a new :test-main image is pulled and
|
||||||
# Opt-in via the "watchtower" profile.
|
# the container recreated automatically — no per-stack Watchtower needed.
|
||||||
watchtower:
|
|
||||||
image: containrrr/watchtower:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
command: --label-enable --cleanup --interval 120
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ${HOME:-/root}/.docker/config.json:/config.json:ro
|
|
||||||
profiles:
|
|
||||||
- watchtower
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
+26
-14
@@ -1,44 +1,56 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* Brand palette (docs/brand): warm ink + bronze + paper. */
|
/* Brand palette + type (docs/brand): warm ink + bronze + paper, serif display. */
|
||||||
@theme {
|
@theme {
|
||||||
--color-bronze: #a06a42;
|
--color-bronze: #a06a42;
|
||||||
--color-bronze-deep: #8a5836;
|
--color-bronze-deep: #8a5836;
|
||||||
--color-paper: #f7f3ec;
|
--color-paper: #f7f3ec;
|
||||||
--color-ink: #1a1a17;
|
--color-ink: #1a1a17;
|
||||||
|
|
||||||
--font-serif: Georgia, "Times New Roman", "Liberation Serif", ui-serif, serif;
|
--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). */
|
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant. */
|
||||||
:root {
|
:root {
|
||||||
--background: #f7f3ec; /* paper */
|
--background: #f7f3ec;
|
||||||
--foreground: #1a1a17; /* ink */
|
--foreground: #1a1a17;
|
||||||
--muted: #6b6862;
|
--muted: #6b6862;
|
||||||
--surface: #fbf8f2;
|
--surface: #fffdf9;
|
||||||
--border: #e4dccb;
|
--border: #e6ddcc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #1a1a17; /* warm near-black */
|
--background: #161410;
|
||||||
--foreground: #f2eee6; /* warm off-white */
|
--foreground: #f2eee6;
|
||||||
--muted: #9a968e;
|
--muted: #9a968e;
|
||||||
--surface: #232019;
|
--surface: #211d17;
|
||||||
--border: #3a352c;
|
--border: #353029;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
/* 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);
|
color: var(--foreground);
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Headings use the heritage serif register. */
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
.font-serif {
|
.font-serif {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-12
@@ -1,38 +1,55 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Fraunces, Inter } from "next/font/google";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import "./globals.css";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Provenance",
|
title: "Provenance — where it came from matters",
|
||||||
description: "Where it came from matters — family and land, every fact sourced.",
|
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" },
|
icons: { icon: "/favicon.svg" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
||||||
<body className="flex min-h-screen flex-col">
|
<body className="flex min-h-screen flex-col antialiased">
|
||||||
<header className="border-b border-[var(--border)]">
|
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]">
|
||||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
<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">
|
<Link href="/" className="flex items-center" aria-label="Provenance — home">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex gap-5 text-sm">
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-bronze">
|
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-[var(--foreground)]">
|
||||||
Trees
|
Trees
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/login" className="text-[var(--muted)] transition-colors hover:text-bronze">
|
<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
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-10">{children}</main>
|
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-10">{children}</main>
|
||||||
|
|
||||||
<footer className="border-t border-[var(--border)]">
|
<footer className="border-t border-[var(--border)]">
|
||||||
<div className="mx-auto max-w-3xl px-4 py-6 text-sm italic text-[var(--muted)]">
|
<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)]">
|
||||||
where it came from matters
|
<span className="font-serif text-base italic">where it came from matters</span>
|
||||||
|
<span>Self-hosted · source-available · your data, your infrastructure</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+68
-18
@@ -1,27 +1,77 @@
|
|||||||
|
import { BadgeCheck, MapPin, ShieldCheck, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 py-4">
|
<div className="space-y-20 py-6 sm:py-12">
|
||||||
<div className="space-y-4">
|
<section className="grid items-center gap-10 sm:grid-cols-[1.3fr_1fr]">
|
||||||
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
|
<div>
|
||||||
Where it came from matters
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
|
||||||
</h1>
|
Family · Land · Provenance
|
||||||
<p className="max-w-prose text-lg text-[var(--muted)]">
|
</p>
|
||||||
Trace where you come from — your family <span className="text-bronze">and</span> your
|
<h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
|
||||||
land — with every fact linked to a source, on infrastructure you control.
|
Where it came from{" "}
|
||||||
</p>
|
<span className="italic text-bronze">matters</span>.
|
||||||
</div>
|
</h1>
|
||||||
<div className="flex flex-wrap gap-3">
|
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
|
||||||
<Link href="/register">
|
Trace your family and your land in one place — every name, every parcel, every claim
|
||||||
<Button>Create an account</Button>
|
linked to the record it came from. Self-hosted, sourced, and yours to keep.
|
||||||
</Link>
|
</p>
|
||||||
<Link href="/login">
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
<Button variant="outline">Sign in</Button>
|
<Link href="/register">
|
||||||
</Link>
|
<Button size="lg">Create your account</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,14 @@ export default function TreeDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
<div className="flex items-center justify-between">
|
||||||
← All trees
|
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
||||||
</Link>
|
← All trees
|
||||||
|
</Link>
|
||||||
|
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
|
||||||
|
Sources →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -81,11 +86,15 @@ export default function TreeDetailPage() {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{persons.map((person) => (
|
{persons.map((person) => (
|
||||||
<li key={person.id}>
|
<li key={person.id}>
|
||||||
<Card>
|
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
||||||
<CardContent className="p-4">
|
<Card className="transition-colors hover:border-bronze/50">
|
||||||
{person.primary_name ?? <span className="text-[var(--muted)]">Unnamed</span>}
|
<CardContent className="p-4">
|
||||||
</CardContent>
|
{person.primary_name ?? (
|
||||||
</Card>
|
<span className="text-[var(--muted)]">Unnamed</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,19 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
// Bronze is the brand accent; paper reads cleanly on it.
|
default: "bg-bronze text-paper shadow-sm hover:bg-bronze-deep hover:shadow",
|
||||||
default: "bg-bronze text-paper hover:bg-bronze-deep",
|
|
||||||
outline:
|
outline:
|
||||||
"border border-bronze text-bronze bg-transparent hover:bg-bronze hover:text-paper",
|
"border border-[var(--border)] bg-[var(--surface)] hover:border-bronze hover:text-bronze",
|
||||||
ghost: "text-[var(--foreground)] hover:bg-bronze/10",
|
ghost: "text-[var(--foreground)] hover:bg-bronze/10",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 text-sm",
|
||||||
sm: "h-9 px-3",
|
sm: "h-9 px-3 text-sm",
|
||||||
|
lg: "h-12 px-6 text-base",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "default", size: "default" },
|
defaultVariants: { variant: "default", size: "default" },
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-sm",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
|
|||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm placeholder:text-[var(--muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze disabled:opacity-50",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Vendored
+884
@@ -210,10 +210,330 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Person */
|
||||||
|
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/events": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Create Event */
|
||||||
|
post: operations["create_event_api_v1_trees__tree_id__events_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/events": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Person Events */
|
||||||
|
get: operations["list_person_events_api_v1_trees__tree_id__persons__person_id__events_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/events/{event_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Event */
|
||||||
|
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/relationships": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Create Relationship */
|
||||||
|
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Person Relationships */
|
||||||
|
get: operations["list_person_relationships_api_v1_trees__tree_id__persons__person_id__relationships_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/relationships/{relationship_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Relationship */
|
||||||
|
delete: operations["delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/sources": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Sources */
|
||||||
|
get: operations["list_sources_api_v1_trees__tree_id__sources_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Create Source */
|
||||||
|
post: operations["create_source_api_v1_trees__tree_id__sources_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/sources/{source_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Source */
|
||||||
|
get: operations["get_source_api_v1_trees__tree_id__sources__source_id__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Source */
|
||||||
|
delete: operations["delete_source_api_v1_trees__tree_id__sources__source_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/citations": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Citations */
|
||||||
|
get: operations["list_citations_api_v1_trees__tree_id__citations_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Create Citation */
|
||||||
|
post: operations["create_citation_api_v1_trees__tree_id__citations_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/citations/{citation_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Citation */
|
||||||
|
delete: operations["delete_citation_api_v1_trees__tree_id__citations__citation_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
/**
|
||||||
|
* CitationConfidence
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
CitationConfidence: "high" | "medium" | "low";
|
||||||
|
/** CitationCreate */
|
||||||
|
CitationCreate: {
|
||||||
|
/**
|
||||||
|
* Source Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
source_id: string;
|
||||||
|
/** Person Id */
|
||||||
|
person_id?: string | null;
|
||||||
|
/** Event Id */
|
||||||
|
event_id?: string | null;
|
||||||
|
/** Name Id */
|
||||||
|
name_id?: string | null;
|
||||||
|
/** Relationship Id */
|
||||||
|
relationship_id?: string | null;
|
||||||
|
/** Page */
|
||||||
|
page?: string | null;
|
||||||
|
/** Detail */
|
||||||
|
detail?: string | null;
|
||||||
|
confidence?: components["schemas"]["CitationConfidence"] | null;
|
||||||
|
};
|
||||||
|
/** CitationRead */
|
||||||
|
CitationRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
/**
|
||||||
|
* Source Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
source_id: string;
|
||||||
|
/** Person Id */
|
||||||
|
person_id: string | null;
|
||||||
|
/** Event Id */
|
||||||
|
event_id: string | null;
|
||||||
|
/** Name Id */
|
||||||
|
name_id: string | null;
|
||||||
|
/** Relationship Id */
|
||||||
|
relationship_id: string | null;
|
||||||
|
/** Page */
|
||||||
|
page: string | null;
|
||||||
|
/** Detail */
|
||||||
|
detail: string | null;
|
||||||
|
confidence: components["schemas"]["CitationConfidence"] | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
/** EventCreate */
|
||||||
|
EventCreate: {
|
||||||
|
/** Event Type */
|
||||||
|
event_type: string;
|
||||||
|
/** Person Id */
|
||||||
|
person_id?: string | null;
|
||||||
|
/** Relationship Id */
|
||||||
|
relationship_id?: string | null;
|
||||||
|
/** Place Id */
|
||||||
|
place_id?: string | null;
|
||||||
|
/** Date Value */
|
||||||
|
date_value?: string | null;
|
||||||
|
/** Date Start */
|
||||||
|
date_start?: string | null;
|
||||||
|
/** Date End */
|
||||||
|
date_end?: string | null;
|
||||||
|
/** Date Precision */
|
||||||
|
date_precision?: string | null;
|
||||||
|
/**
|
||||||
|
* Calendar
|
||||||
|
* @default gregorian
|
||||||
|
*/
|
||||||
|
calendar?: string;
|
||||||
|
/** Detail */
|
||||||
|
detail?: string | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
/** EventRead */
|
||||||
|
EventRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
/** Event Type */
|
||||||
|
event_type: string;
|
||||||
|
/** Person Id */
|
||||||
|
person_id: string | null;
|
||||||
|
/** Relationship Id */
|
||||||
|
relationship_id: string | null;
|
||||||
|
/** Place Id */
|
||||||
|
place_id: string | null;
|
||||||
|
/** Date Value */
|
||||||
|
date_value: string | null;
|
||||||
|
/** Date Start */
|
||||||
|
date_start: string | null;
|
||||||
|
/** Date End */
|
||||||
|
date_end: string | null;
|
||||||
|
/** Date Precision */
|
||||||
|
date_precision: string | null;
|
||||||
|
/** Calendar */
|
||||||
|
calendar: string;
|
||||||
|
/** Detail */
|
||||||
|
detail: string | null;
|
||||||
|
/** Notes */
|
||||||
|
notes: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
/** HTTPValidationError */
|
/** HTTPValidationError */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
@@ -226,6 +546,13 @@ export interface components {
|
|||||||
/** Password */
|
/** Password */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ParentChildQualifier
|
||||||
|
* @description Qualifies a parent_child edge so adoption/donor/blended families are
|
||||||
|
* first-class rather than edge cases (ARCHITECTURE §5).
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
ParentChildQualifier: "biological" | "adoptive" | "step" | "foster" | "donor" | "guardian";
|
||||||
/** PasswordResetConfirm */
|
/** PasswordResetConfirm */
|
||||||
PasswordResetConfirm: {
|
PasswordResetConfirm: {
|
||||||
/** Token */
|
/** Token */
|
||||||
@@ -293,6 +620,60 @@ export interface components {
|
|||||||
/** Display Name */
|
/** Display Name */
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
};
|
};
|
||||||
|
/** RelationshipCreate */
|
||||||
|
RelationshipCreate: {
|
||||||
|
type: components["schemas"]["RelationshipType"];
|
||||||
|
/**
|
||||||
|
* Person From Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
person_from_id: string;
|
||||||
|
/**
|
||||||
|
* Person To Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
person_to_id: string;
|
||||||
|
qualifier?: components["schemas"]["ParentChildQualifier"] | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
/** RelationshipRead */
|
||||||
|
RelationshipRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
type: components["schemas"]["RelationshipType"];
|
||||||
|
/**
|
||||||
|
* Person From Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
person_from_id: string;
|
||||||
|
/**
|
||||||
|
* Person To Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
person_to_id: string;
|
||||||
|
qualifier: components["schemas"]["ParentChildQualifier"] | null;
|
||||||
|
/** Notes */
|
||||||
|
notes: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* RelationshipType
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
RelationshipType: "parent_child" | "partnership" | "sibling";
|
||||||
/** SessionRead */
|
/** SessionRead */
|
||||||
SessionRead: {
|
SessionRead: {
|
||||||
user: components["schemas"]["UserRead"];
|
user: components["schemas"]["UserRead"];
|
||||||
@@ -304,6 +685,59 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
};
|
};
|
||||||
|
/** SourceCreate */
|
||||||
|
SourceCreate: {
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
/** Author */
|
||||||
|
author?: string | null;
|
||||||
|
/** Source Type */
|
||||||
|
source_type?: string | null;
|
||||||
|
/** Repository */
|
||||||
|
repository?: string | null;
|
||||||
|
/** Url */
|
||||||
|
url?: string | null;
|
||||||
|
/** Citation Text */
|
||||||
|
citation_text?: string | null;
|
||||||
|
/** Publication Info */
|
||||||
|
publication_info?: string | null;
|
||||||
|
/** Quality Note */
|
||||||
|
quality_note?: string | null;
|
||||||
|
};
|
||||||
|
/** SourceRead */
|
||||||
|
SourceRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
/** Author */
|
||||||
|
author: string | null;
|
||||||
|
/** Source Type */
|
||||||
|
source_type: string | null;
|
||||||
|
/** Repository */
|
||||||
|
repository: string | null;
|
||||||
|
/** Url */
|
||||||
|
url: string | null;
|
||||||
|
/** Citation Text */
|
||||||
|
citation_text: string | null;
|
||||||
|
/** Publication Info */
|
||||||
|
publication_info: string | null;
|
||||||
|
/** Quality Note */
|
||||||
|
quality_note: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
/** TokenRequest */
|
/** TokenRequest */
|
||||||
TokenRequest: {
|
TokenRequest: {
|
||||||
/** Token */
|
/** Token */
|
||||||
@@ -782,4 +1216,454 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
get_person_api_v1_trees__tree_id__persons__person_id__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
person_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PersonRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_event_api_v1_trees__tree_id__events_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EventCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EventRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_person_events_api_v1_trees__tree_id__persons__person_id__events_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
person_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EventRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_event_api_v1_trees__tree_id__events__event_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
event_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_relationship_api_v1_trees__tree_id__relationships_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RelationshipCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RelationshipRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_person_relationships_api_v1_trees__tree_id__persons__person_id__relationships_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
person_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RelationshipRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
relationship_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_sources_api_v1_trees__tree_id__sources_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SourceRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_source_api_v1_trees__tree_id__sources_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SourceCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SourceRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_source_api_v1_trees__tree_id__sources__source_id__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
source_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SourceRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_source_api_v1_trees__tree_id__sources__source_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
source_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_citations_api_v1_trees__tree_id__citations_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CitationRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_citation_api_v1_trees__tree_id__citations_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CitationCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CitationRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_citation_api_v1_trees__tree_id__citations__citation_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
citation_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user