feat(backend): add durable MAX linking and notification queue

Refs #82
This commit is contained in:
Pavel Eliseev
2026-10-04 16:04:38 +03:00
parent db3072d759
commit 64655ad934
8 changed files with 588 additions and 2 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Server API for a future MAX connect UI; webhook processing is supplied by the channel adapter."""
import hmac
import json
import re
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from app.config import get_settings
from app.core import clock
from app.db import repo
from app.db.max_notifications import MaxBinding, MaxDelivery
from app.db.session import get_clinical_session, get_identity_session
from app.integrations import max_store
router = APIRouter(tags=["max-notifications"])
bearer = HTTPBearer(auto_error=False)
async def patient_token(auth: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> str:
if auth is None or auth.scheme.lower() != "bearer" or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", auth.credentials):
raise HTTPException(401, "patient authentication required", headers={"WWW-Authenticate": "Bearer"})
async with get_clinical_session() as session:
if await repo.get_profile(session, auth.credentials) is None:
raise HTTPException(401, "patient authentication required")
return auth.credentials
PatientToken = Annotated[str, Depends(patient_token)]
class MaxStatus(BaseModel):
available: bool
connected: bool
last_delivery: str | None = None
class LinkConsent(BaseModel):
model_config = ConfigDict(extra="forbid")
consent: Literal[True]
class MaxLink(BaseModel):
url: str
expires_at: datetime
@router.get("/me/notifications/max", response_model=MaxStatus)
async def status(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
last = await session.scalar(
select(MaxDelivery.state)
.where(MaxDelivery.token == token)
.order_by(MaxDelivery.created_at.desc(), MaxDelivery.id.desc())
.limit(1)
)
return MaxStatus(
available=get_settings().max_enabled, connected=bool(binding and binding.enabled), last_delivery=last
)
@router.post("/me/notifications/max/link", response_model=MaxLink)
async def link(body: LinkConsent, token: PatientToken) -> MaxLink:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
if not await max_store.active_patient(token):
raise HTTPException(403, "active consent and adult confirmation required")
async with get_identity_session() as session:
raw, expires = await max_store.issue_code(session, token, clock.now(), settings.max_link_ttl_seconds)
return MaxLink(url=f"https://max.ru/{settings.max_bot_username}?start={raw}", expires_at=expires)
@router.delete("/me/notifications/max", response_model=MaxStatus)
async def unlink(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
await max_store.disconnect(session, token)
return MaxStatus(available=get_settings().max_enabled, connected=False)
@router.post("/webhooks/max")
async def webhook(request: Request) -> dict[str, bool]:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
expected = settings.max_webhook_secret.get_secret_value().encode()
supplied = request.headers.get("X-Max-Bot-Api-Secret", "").encode()
if not expected or not hmac.compare_digest(supplied, expected):
raise HTTPException(403, "invalid webhook secret")
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 65536:
raise HTTPException(413, "event is too large")
try:
event = json.loads(raw)
except (ValueError, UnicodeError) as exc:
raise HTTPException(400, "invalid JSON") from exc
if not isinstance(event, dict):
raise HTTPException(422, "event must be an object")
from app.channels.max_bot import handle_update
await handle_update(event)
return {"success": True}
+2 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy import select
from app.api import demo as console
from app.api import doctor_views as views
from app.api import fixtures
from app.api import fixtures, max_notifications
from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import (
@@ -46,6 +46,7 @@ from app.scheduler import jobs as scheduler
from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1")
router.include_router(max_notifications.router)
router.include_router(questionnaire_router)
router.include_router(patient_web_router)
+1
View File
@@ -5,6 +5,7 @@ The only module (besides registration code) that touches both schemas — and on
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
from app.db import intake as _intake # noqa: F401
from app.db import max_notifications as _max_notifications # noqa: F401
from app.db import scheduler as _scheduler # noqa: F401
from app.db.clinical import ClinicalBase
from app.db.identity import IdentityBase
+50
View File
@@ -0,0 +1,50 @@
"""MAX identifiers and delivery metadata live only in the identity DB; no medical message bodies."""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.identity import IdentityBase
from app.db.types import TZDateTime
class MaxBinding(IdentityBase):
__tablename__ = "max_bindings"
# No FK: anonymous web profiles intentionally have no patients row in identity.
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, unique=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
connected_at: Mapped[datetime] = mapped_column(TZDateTime)
class MaxLinkCode(IdentityBase):
__tablename__ = "max_link_codes"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
used_at: Mapped[datetime | None] = mapped_column(TZDateTime)
class MaxReceipt(IdentityBase):
__tablename__ = "max_webhook_receipts"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
received_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
class MaxDelivery(IdentityBase):
__tablename__ = "max_deliveries"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int] = mapped_column(BigInteger)
kind: Mapped[str] = mapped_column(String(24))
state: Mapped[str] = mapped_column(String(16), default="queued", index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(TZDateTime)
next_attempt_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
lease: Mapped[str | None] = mapped_column(String(32))
error_code: Mapped[str | None] = mapped_column(String(32))
+201
View File
@@ -0,0 +1,201 @@
"""Registration boundary and durable MAX queue. Callers own the identity transaction.
Only this boundary joins a pseudonymous profile to a MAX recipient. Clinical modules never import it.
Queue rows contain a template name, never replies, scores, prescriptions or patient-auth tokens in URLs.
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import repo
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.db.session import get_clinical_session
MAX_ATTEMPTS = 6
LEASE_SECONDS = 60
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
async def active_patient(token: str) -> bool:
"""Same consent/adult gate used by the scheduler, checked again at delivery time."""
async with get_clinical_session() as session:
state = await repo.get_dialog_state(session, token)
return bool(
await repo.get_profile(session, token)
and state
and state.data.get("consent") is True
and state.data.get("adult") is True
)
async def issue_code(session: AsyncSession, token: str, now: datetime, ttl: int) -> tuple[str, datetime]:
raw = secrets.token_urlsafe(32)
expires = now + timedelta(seconds=ttl)
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
session.add(MaxLinkCode(digest=digest(raw), token=token, expires_at=expires))
await session.flush()
return raw, expires
async def code_token(session: AsyncSession, raw: str, now: datetime) -> str | None:
row = await session.get(MaxLinkCode, digest(raw))
return row.token if row and row.used_at is None and row.expires_at > now else None
async def bind(session: AsyncSession, raw: str, user_id: int, now: datetime) -> str | None:
"""Atomically consume a code. Existing active identities cannot be silently reassigned."""
token = await code_token(session, raw, now)
if token is None:
return None
owner = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
binding = await session.get(MaxBinding, token)
if owner and owner.token != token and owner.enabled:
return None
if binding and binding.user_id != user_id and binding.enabled:
return None
used = await session.execute(
update(MaxLinkCode)
.where(MaxLinkCode.digest == digest(raw), MaxLinkCode.used_at.is_(None), MaxLinkCode.expires_at > now)
.values(used_at=now)
.returning(MaxLinkCode.token)
)
if used.scalar_one_or_none() is None:
return None
if owner and owner.token != token:
await session.delete(owner)
await session.flush()
if binding is None:
binding = MaxBinding(token=token, user_id=user_id, connected_at=now)
session.add(binding)
# Cancel stale deliveries even when the same account reconnects later.
await cancel_pending(session, token)
binding.user_id, binding.enabled, binding.connected_at = user_id, True, now
await session.flush()
return token
async def cancel_pending(session: AsyncSession, token: str) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.token == token, MaxDelivery.state.in_(("queued", "sending")))
.values(state="cancelled", lease=None)
)
async def disconnect(session: AsyncSession, token: str) -> None:
binding = await session.get(MaxBinding, token)
if binding:
binding.enabled = False
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
await cancel_pending(session, token)
async def enqueue(
session: AsyncSession, *, key: str, user_id: int, kind: str, now: datetime, token: str | None = None
) -> None:
if await session.get(MaxDelivery, key) is not None:
return
try:
async with session.begin_nested():
session.add(
MaxDelivery(id=key, user_id=user_id, token=token, kind=kind, created_at=now, next_attempt_at=now)
)
await session.flush()
except IntegrityError:
# Another worker may enqueue the same event between SELECT and INSERT.
if await session.get(MaxDelivery, key) is None:
raise
async def collect_completed(session: AsyncSession, now: datetime) -> None:
"""Recover completion events from committed clinical records, including after a restart.
No clinical hooks depend on identity. Read only IDs/dates, then enqueue a generic template.
A binding's activation time bounds history; repeated scans are idempotent.
"""
bindings = (await session.scalars(select(MaxBinding).where(MaxBinding.enabled.is_(True)))).all()
for binding in bindings:
if not await active_patient(binding.token):
continue
async with get_clinical_session() as clinical:
forms = (
await clinical.scalars(
select(QuestionnaireRecord.id).where(
QuestionnaireRecord.token == binding.token,
QuestionnaireRecord.status == "complete",
QuestionnaireRecord.completed_at >= binding.connected_at,
)
)
).all()
scales = (
await clinical.scalars(
select(ScaleResult.id).where(
ScaleResult.token == binding.token,
ScaleResult.complete.is_(True),
ScaleResult.ts >= binding.connected_at,
)
)
).all()
for source, ids in (("form", forms), ("scale", scales)):
for record_id in ids:
key = digest(f"completed:{binding.token}:{binding.connected_at.isoformat()}:{source}:{record_id}")
await enqueue(session, key=key, token=binding.token, user_id=binding.user_id, kind="completed", now=now)
async def claim(session: AsyncSession, now: datetime) -> MaxDelivery | None:
"""One lease per row; recovery after a worker crash is bounded by the lease deadline."""
ready = (MaxDelivery.state.in_(("queued", "sending"))) & (MaxDelivery.next_attempt_at <= now)
row = await session.scalar(
select(MaxDelivery)
.where(ready)
.order_by(MaxDelivery.created_at, MaxDelivery.id)
.with_for_update(skip_locked=True)
.limit(1)
)
if row is None:
return None
if row.attempts >= MAX_ATTEMPTS:
row.state, row.error_code, row.lease = "failed", "attempt_limit", None
return None
lease = secrets.token_hex(16)
result = await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, ready)
.values(
state="sending",
attempts=MaxDelivery.attempts + 1,
lease=lease,
next_attempt_at=now + timedelta(seconds=LEASE_SECONDS),
)
.returning(MaxDelivery.id)
)
if result.scalar_one_or_none() is None:
return None
await session.refresh(row)
return row
async def finish(
session: AsyncSession,
row: MaxDelivery,
*,
state: str,
now: datetime,
error: str | None = None,
retry_seconds: float = 0,
) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, MaxDelivery.lease == row.lease, MaxDelivery.state == "sending")
.values(state=state, lease=None, error_code=error, next_attempt_at=now + timedelta(seconds=retry_seconds))
)