"""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 sqlalchemy.exc import SQLAlchemyError 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 try: await handle_update(event) except SQLAlchemyError: # Let MAX retry; do not log DB exception parameters containing identity data. raise HTTPException(503, "notification storage unavailable") from None return {"success": True}