"""API routes `/api/v1` (#34 stubs → #12 implementation). Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately. Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract). """ from fastapi import APIRouter, HTTPException from app.api import fixtures 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 ( AckIn, AlertOut, ClockAdvanceIn, ClockOut, ConnectPatientIn, ConnectPatientOut, DemoPersona, DialogIn, DialogOut, MessageOut, PatientCard, PatientListItem, ) from app.core import clock from app.dialog.contracts import get_engine from app.integrations.base import MemoryOutbox, integrations from app.scheduler import jobs as scheduler router = APIRouter(prefix="/api/v1") router.include_router(questionnaire_router) router.include_router(patient_web_router) # ------------------------------------------------------------------ doctor dashboard @router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"]) async def list_patients(doctor_id: str) -> list[PatientListItem]: """Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk.""" order = {"red": 0, "yellow": 1, "grey": 2, "green": 3} return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score)) @router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"]) async def patient_card(token: str, doctor_id: str) -> PatientCard: """Patient card. Every call is written to audit_log as `view_patient` (#12).""" card = fixtures.card(token) if card is None: raise HTTPException(404, "patient not found") return card @router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"]) async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]: """Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first.""" alerts = [a for a in fixtures.all_alerts() if a.id > since_id] return [a for a in alerts if a.acked_at is None] if open_only else alerts @router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"]) async def ack_alert(alert_id: int, body: AckIn) -> AlertOut: """Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis.""" alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None) if alert is None: raise HTTPException(404, "alert not found") return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()}) @router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"]) async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut: """Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR.""" return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001") # ------------------------------------------------------------------ patient dialog (web console / any channel) @router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"]) async def dialog_event(token: str, body: DialogIn) -> DialogOut: return DialogOut(replies=await get_engine().handle(token, body)) @router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"]) async def dialog_outbox(token: str) -> DialogOut: """System-initiated replies (reminders, check-ins) waiting for the web console.""" box = integrations.outbox return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else []) @router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"]) async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]: return [] # ------------------------------------------------------------------ demo controls (ADR-005) def _clock_out() -> ClockOut: return ClockOut(now=clock.now(), virtual=clock._virtual(), offset_days=clock.offset().total_seconds() / 86400) @router.get("/demo/clock", response_model=ClockOut, tags=["demo"]) async def get_clock() -> ClockOut: return _clock_out() @router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"]) async def advance_clock(body: ClockAdvanceIn) -> ClockOut: """Moves the virtual clock and replays the scheduler up to the new time (#13).""" clock.advance(days=body.days, hours=body.hours) await scheduler.run_until_now() return _clock_out() @router.post("/demo/reset", tags=["demo"]) async def demo_reset() -> dict[str, str]: clock.reset() return {"status": "ok"} @router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"]) async def demo_personas() -> list[DemoPersona]: return fixtures.DEMO_PERSONAS