A second channel to the same dialog engine as the Telegram bot: consent, onboarding, scales and check-ins come from app/dialog; the bot is unchanged. - API: /auth/emias (doctor's one-time connect code via EmiasMock), /auth/emias/demo (synthetic personas), /auth/anonymous (random token, nothing stored in the identity DB), /crisis/resources from crisis.yaml - web/: React + TS + Vite, mobile-first, safe areas, hash routing, tokens and texts isolated for a Capacitor mobile build Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
94 lines
3.9 KiB
Python
94 lines
3.9 KiB
Python
"""Patient web app: sign-in and public reference data (`/api/v1/auth/*`, `/api/v1/crisis/resources`).
|
|
|
|
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
|
|
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
|
|
|
|
Two ways in:
|
|
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
|
|
patient is linked to the attending doctor, who sees the data on the dashboard.
|
|
- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine
|
|
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
|
|
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
|
|
|
|
The token returned here is the session credential of the prototype. Production needs short-lived sessions
|
|
issued by the server instead of handing out the pseudonymous token itself.
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from app.api import fixtures
|
|
from app.core.safety import CrisisResource, crisis_reply
|
|
from app.db import repo
|
|
from app.db.identity import TOKEN_LEN
|
|
from app.db.session import get_clinical_session
|
|
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["patient-web"])
|
|
_emias = EmiasMock()
|
|
|
|
|
|
class AuthOut(BaseModel):
|
|
token: str
|
|
mode: Literal["emias", "anonymous"]
|
|
display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
|
|
|
|
|
|
class EmiasCodeIn(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
|
|
|
|
|
|
class EmiasDemoIn(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
persona_token: str = Field(min_length=1, max_length=64)
|
|
|
|
|
|
async def _attach_doctor(token: str, doctor_id: str) -> None:
|
|
async with get_clinical_session() as s:
|
|
if await repo.get_profile(s, token) is None:
|
|
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
|
|
|
|
|
|
@router.post("/auth/emias", response_model=AuthOut)
|
|
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
|
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
|
|
person = await _emias.verify(body.code)
|
|
if person is None:
|
|
log.info("web auth: emias code rejected")
|
|
raise HTTPException(401, "code not accepted")
|
|
await _attach_doctor(person.token, person.doctor_id)
|
|
log.info("web auth: emias tok=%s", person.token[:6])
|
|
return AuthOut(token=person.token, mode="emias")
|
|
|
|
|
|
@router.post("/auth/emias/demo", response_model=AuthOut)
|
|
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
|
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/demo/personas`)."""
|
|
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
|
|
if persona is None:
|
|
raise HTTPException(404, "persona not found")
|
|
await _attach_doctor(persona.token, fixtures.DOCTOR_ID)
|
|
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
|
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
|
|
|
|
|
@router.post("/auth/anonymous", response_model=AuthOut)
|
|
async def auth_anonymous() -> AuthOut:
|
|
"""Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere."""
|
|
token = secrets.token_hex(TOKEN_LEN // 2)
|
|
log.info("web auth: anonymous tok=%s", token[:6])
|
|
return AuthOut(token=token, mode="anonymous")
|
|
|
|
|
|
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
|
async def crisis_resources() -> list[CrisisResource]:
|
|
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
|
return crisis_reply([], "patient").resources
|