feat(web): patient web app with ЕМИАС (demo) and anonymous sign-in
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>
This commit is contained in:
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from app.api.schemas import (
|
||||
AlertOut,
|
||||
DemoPersona,
|
||||
DiscontinuationRisk,
|
||||
DoseOut,
|
||||
PatientCard,
|
||||
@@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow")
|
||||
TODAY = date(2026, 10, 3)
|
||||
DOCTOR_ID = "doc-smirnova"
|
||||
|
||||
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
|
||||
DEMO_PERSONAS: list[DemoPersona] = [
|
||||
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
|
||||
]
|
||||
|
||||
|
||||
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
|
||||
level = "high" if score >= 60 else "medium" if score >= 30 else "low"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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
|
||||
+3
-5
@@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f
|
||||
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,
|
||||
@@ -29,6 +30,7 @@ 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
|
||||
@@ -114,8 +116,4 @@ async def demo_reset() -> dict[str, str]:
|
||||
|
||||
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
|
||||
async def demo_personas() -> list[DemoPersona]:
|
||||
return [
|
||||
DemoPersona(
|
||||
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
|
||||
)
|
||||
]
|
||||
return fixtures.DEMO_PERSONAS
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code).
|
||||
|
||||
Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect
|
||||
code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The
|
||||
class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel.
|
||||
|
||||
It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice
|
||||
(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the
|
||||
production provider for all channels (#14).
|
||||
|
||||
This module may use the identity DB: it is the registration boundary (guardrail #5).
|
||||
"""
|
||||
|
||||
from app.core import clock
|
||||
from app.db import identity_repo
|
||||
from app.db.identity import ConnectCode
|
||||
from app.db.session import get_identity_session
|
||||
from app.integrations.base import VerifiedPerson
|
||||
|
||||
CODE_MAX_LEN = 32
|
||||
|
||||
|
||||
class EmiasMock:
|
||||
async def verify(self, auth_code: str) -> VerifiedPerson | None:
|
||||
code = auth_code.strip().upper()
|
||||
if not code or len(code) > CODE_MAX_LEN:
|
||||
return None
|
||||
async with get_identity_session() as s:
|
||||
row = await s.get(ConnectCode, code)
|
||||
if row is None:
|
||||
return None
|
||||
token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now())
|
||||
if token is None:
|
||||
return None
|
||||
return VerifiedPerson(token=token, doctor_id=row.doctor_id)
|
||||
Reference in New Issue
Block a user