Compare commits

...
Author SHA1 Message Date
Pavel Eliseev f5987c72f0 fix(web): offer anonymous screening and doctor contact without identity
CI / ruff · pytest (pull_request) Successful in 2m30s
Refs #95, #96
2026-10-04 16:48:03 +03:00
Pavel Eliseev 0743184f3d fix(api): provide pseudonymous patients access to doctor care 2026-10-04 16:47:06 +03:00
Arxip222 079c3003aa Merge pull request 'fix(clinical): красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров' (#94) from fix/clinical-review-safety into dev
CI / ruff · pytest (push) Successful in 2m26s
2026-10-04 16:28:28 +03:00
Eleboba dff8f316bb Merge pull request 'chore(devops): HTTPS для meditron.tenvex.ru' (#93) from chore/nginx-https-prod into dev
CI / ruff · pytest (push) Successful in 2m14s
2026-10-04 16:25:26 +03:00
ElebobaandClaude Opus 5.5 f14efac7ac chore(devops): HTTPS for meditron.tenvex.ru too
CI / ruff · pytest (pull_request) Successful in 2m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:22:49 +03:00
Eleboba 766c2a2e4b Merge pull request 'chore(devops): HTTPS для dev.meditron.tenvex.ru' (#91) from chore/nginx-https into dev
CI / ruff · pytest (push) Successful in 2m16s
2026-10-04 16:21:49 +03:00
ElebobaandClaude Opus 5.5 a3180ad232 chore(devops): HTTPS for dev.meditron.tenvex.ru, meditron.tenvex.ru on HTTP until its DNS record
CI / ruff · pytest (pull_request) Successful in 2m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:19:19 +03:00
13 changed files with 288 additions and 71 deletions
+11 -1
View File
@@ -34,6 +34,7 @@ from app.db import repo
from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
from app.db.identity import Patient from app.db.identity import Patient
from app.integrations.base import Prescription as PrescriptionOut from app.integrations.base import Prescription as PrescriptionOut
from app.services.anonymous_care import display_name as anonymous_name
COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3} COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
_MODULES = frozenset(get_args(Module)) _MODULES = frozenset(get_args(Module))
@@ -96,6 +97,7 @@ class PatientData:
doses: Sequence[Dose] doses: Sequence[Dose]
prescriptions: Sequence[Prescription] prescriptions: Sequence[Prescription]
alerts: Sequence[Alert] # newest first alerts: Sequence[Alert] # newest first
contact_requested_at: datetime | None = None
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData: async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData:
@@ -104,6 +106,9 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
rx = ( rx = (
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id)) await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
).all() ).all()
dialog = await repo.get_dialog_state(session, token)
requests = (dialog.data.get("contact_requests") or []) if dialog else []
contact_at = datetime.fromisoformat(requests[-1]["ts"]) if requests else None
return PatientData( return PatientData(
profile=profile, profile=profile,
checkins=await repo.list_checkins(session, token), checkins=await repo.list_checkins(session, token),
@@ -111,6 +116,7 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
doses=doses, doses=doses,
prescriptions=rx, prescriptions=rx,
alerts=await repo.list_alerts(session, token=token), alerts=await repo.list_alerts(session, token=token),
contact_requested_at=contact_at,
) )
@@ -228,6 +234,10 @@ def _last_event(d: PatientData, now: datetime) -> str:
alert = d.alerts[0] if d.alerts else None alert = d.alerts[0] if d.alerts else None
scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS] scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
scale = scales[-1] if scales else None scale = scales[-1] if scales else None
if d.contact_requested_at is not None and all(
d.contact_requested_at >= ts for ts in [x.ts for x in (alert, scale) if x is not None]
):
return "Пациент просит врача связаться"
if scale is not None and (alert is None or scale.ts > alert.ts): if scale is not None and (alert is None or scale.ts > alert.ts):
day = _therapy_day(d.profile, _local(scale.ts, now)) day = _therapy_day(d.profile, _local(scale.ts, now))
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})" return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})"
@@ -249,7 +259,7 @@ def list_item(d: PatientData, patient: Patient | None, now: datetime) -> Patient
rx = _active_rx(d, today) rx = _active_rx(d, today)
return PatientListItem( return PatientListItem(
token=p.token, token=p.token,
display_name=patient.display_name if patient else "Пациент", display_name=patient.display_name if patient else anonymous_name(p.token),
age=_age(patient, p, today), age=_age(patient, p, today),
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES], modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
drug=_drug_label(rx) if rx else None, drug=_drug_label(rx) if rx else None,
+30 -7
View File
@@ -7,9 +7,9 @@ exactly like the web console, and consent, onboarding, scales and check-ins come
Two ways in: Two ways in:
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The - **ЕМИАС** (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. 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 - **Anonymous**: a random pseudonymous token, no identity record, and access to the prototype care team.
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is Clinical answers are stored on the server and visible to the assigned doctor under a pseudonym.
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor. Consent is kept in the dialog state. Existing doctor-less profiles require explicit care opt-in.
The token returned here is the session credential of the prototype. Production needs short-lived sessions 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. issued by the server instead of handing out the pseudonymous token itself.
@@ -36,11 +36,13 @@ from app.core.interpretation import texts as results_texts
from app.core.meds_reference import find_drug, indication_for, load_reference from app.core.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply from app.core.safety import CrisisResource, crisis_reply
from app.db import repo from app.db import repo
from app.db.audit import audit
from app.db.clinical import DailyCheckin, Dose, Prescription from app.db.clinical import DailyCheckin, Dose, Prescription
from app.db.identity import TOKEN_LEN from app.db.identity import TOKEN_LEN
from app.db.intake import QuestionnaireRecord from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session from app.db.session import get_clinical_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.services import anonymous_care
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -153,19 +155,40 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
@router.post("/auth/anonymous", response_model=AuthOut) @router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut: async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; no identity record, no doctor. """Anonymous sign-in: a fresh random token; care without an identity record.
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds The clinical side gets a pseudonymous profile assigned to the prototype care team. It holds
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself. no identity fields. Legacy clients may supply `focus`; the common stage 1 screening sets modules itself.
""" """
token = secrets.token_hex(TOKEN_LEN // 2) token = secrets.token_hex(TOKEN_LEN // 2)
focus = body.focus if body is not None else None focus = body.focus if body is not None else None
async with get_clinical_session() as s: async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else []) await repo.upsert_profile(
s, token=token, doctor_id=anonymous_care.doctor_id(), modules=[_FOCUS_MODULE[focus]] if focus else []
)
log.info("web auth: anonymous tok=%s", token[:6]) log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous") return AuthOut(token=token, mode="anonymous")
@router.get("/patient/capabilities")
async def patient_capabilities() -> dict[str, bool]:
"""Expose availability without leaking clinical policy or server configuration."""
return {"primary_screening": intake_enabled()}
@router.post("/me/{token}/doctor/connect")
async def connect_anonymous_doctor(token: str) -> dict[str, bool]:
"""Explicit opt-in for old doctor-less profiles; never reassign an existing doctor."""
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
if profile is None:
raise HTTPException(404, "patient not found")
if not profile.doctor_id:
profile.doctor_id = anonymous_care.doctor_id()
await audit(s, actor=token, action="anonymous_care_opt_in", token=token, ts=clock.now())
return {"has_doctor": True}
class DoseItem(BaseModel): class DoseItem(BaseModel):
id: int id: int
drug: str drug: str
+2 -1
View File
@@ -43,6 +43,7 @@ from app.db.session import get_clinical_session, get_identity_session
from app.dialog.contracts import Event, get_engine from app.dialog.contracts import Event, get_engine
from app.integrations.base import integrations from app.integrations.base import integrations
from app.scheduler import jobs as scheduler from app.scheduler import jobs as scheduler
from app.services.anonymous_care import display_name as anonymous_name
from app.services.monitoring import refresh_status from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
@@ -66,7 +67,7 @@ async def _patients(tokens: list[str]) -> dict[str, Patient]:
def _name(patients: dict[str, Patient], token: str) -> str: def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token) p = patients.get(token)
return p.display_name if p else "Пациент" return p.display_name if p else anonymous_name(token)
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ doctor dashboard
+24
View File
@@ -0,0 +1,24 @@
"""Pseudonymous care routing; no identity fields or changes to clinical release gates."""
from hashlib import sha256
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
class AnonymousCare(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
doctor_id: str = Field(min_length=1, max_length=64)
def doctor_id() -> str:
path = get_settings().config_dir / "anonymous_care.yaml"
return AnonymousCare.model_validate(yaml.safe_load(path.read_text())).doctor_id
def display_name(token: str) -> str:
# Do not use a piece of the bearer token itself as the public patient label.
label = sha256(("anonymous-patient:" + token).encode()).hexdigest()[:10].upper()
return f"Пациент {label}"
+3
View File
@@ -0,0 +1,3 @@
# Routing for the current prototype: anonymous patients use the same doctor workspace,
# without an identity record. Set this to the responsible clinic's doctor when deploying.
doctor_id: doc-smirnova
+1 -1
View File
@@ -23,7 +23,7 @@ services:
- --browser.gatherUsageStats=false - --browser.gatherUsageStats=false
environment: environment:
API_BASE_URL: http://app:8000 API_BASE_URL: http://app:8000
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-http://localhost}/doctor DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-https://dev.meditron.tenvex.ru}/doctor
ports: !override ports: !override
- "127.0.0.1:8511:8501" - "127.0.0.1:8511:8501"
restart: unless-stopped restart: unless-stopped
+69 -2
View File
@@ -1,8 +1,9 @@
# meditron.tenvex.ru — PTSRaiAgent prototype (synthetic data only). # PTSRaiAgent prototype (synthetic data only) — shared host with vobot.
# / patient web app (static, web/dist) # / patient web app (static, web/dist)
# /api/ FastAPI (127.0.0.1:8010) # /api/ FastAPI (127.0.0.1:8010)
# /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor) # /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor)
# TLS: after the DNS A-record exists — certbot --nginx -d meditron.tenvex.ru # dev.meditron.tenvex.ru — HTTPS (Let's Encrypt, certbot certonly --nginx, auto-renew).
# meditron.tenvex.ru — HTTPS (same stack for now; will serve the main branch after promotion).
map $http_upgrade $ptsr_connection_upgrade { map $http_upgrade $ptsr_connection_upgrade {
default upgrade; default upgrade;
@@ -12,7 +13,73 @@ map $http_upgrade $ptsr_connection_upgrade {
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name dev.meditron.tenvex.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name dev.meditron.tenvex.ru;
ssl_certificate /etc/letsencrypt/live/dev.meditron.tenvex.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.meditron.tenvex.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m;
root /srv/ptsr/app/web/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # chat agent + scheduler replay on clock advance
}
location ~ ^/(docs|openapi.json|health)$ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
}
location /doctor/ {
proxy_pass http://127.0.0.1:8511;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $ptsr_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_buffering off;
}
}
server {
listen 80;
listen [::]:80;
server_name meditron.tenvex.ru; server_name meditron.tenvex.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name meditron.tenvex.ru;
ssl_certificate /etc/letsencrypt/live/meditron.tenvex.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/meditron.tenvex.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/ptsr-access.log; access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m; client_max_body_size 5m;
+71 -6
View File
@@ -12,6 +12,7 @@ from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription from app.db.clinical import Dose, Prescription
from app.main import app from app.main import app
from app.services.anonymous_care import doctor_id as anonymous_doctor
MEM = "sqlite+aiosqlite:///:memory:" MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "b" * 32 TOKEN = "b" * 32
@@ -48,17 +49,17 @@ async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: Asy
async with session.get_identity_session() as s: async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
# Only a doctor-less pseudonymous profile: the dialog API serves known profiles only. # The care team sees a pseudonymous profile without an identity record.
profile = await repo.get_profile(s, first["token"]) profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == "" and profile.modules == [] assert profile.doctor_id == anonymous_doctor() and profile.modules == []
async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None: async def test_anonymous_focus_does_not_remove_access_to_doctor(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"] token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token) profile = await repo.get_profile(s, token)
assert profile.modules == ["veteran"] and not profile.doctor_id assert profile.modules == ["veteran"] and profile.doctor_id == anonymous_doctor()
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is True
assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422 assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
@@ -122,7 +123,7 @@ async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None: async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"] token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
body = (await client.get(f"/api/v1/me/{token}/today")).json() body = (await client.get(f"/api/v1/me/{token}/today")).json()
assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == [] assert body["has_doctor"] is True and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None: async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
@@ -220,3 +221,67 @@ async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None: async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json() body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS] assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
async def test_anonymous_is_visible_only_to_assigned_doctor(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()["token"]
second = (await client.post("/api/v1/auth/anonymous")).json()["token"]
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
names = {p["token"]: p["display_name"] for p in items}
assert names[first].startswith("Пациент ") and names[first] != names[second]
assert first not in names[first]
own = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": anonymous_doctor()})
assert own.status_code == 200 and own.json()["patient"]["display_name"] == names[first]
denied = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": "another-doctor"})
assert denied.status_code == 403
assert (await client.get("/api/v1/doctors/another-doctor/patients")).json() == []
async def test_old_anonymous_profile_requires_explicit_care_opt_in(client: AsyncClient) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
# Merely opening Today must not reveal an old private profile to a doctor.
assert not (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["has_doctor"]
assert (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json() == []
for _ in range(2):
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).json() == {"has_doctor": True}
async with session.get_clinical_session() as s:
p = await repo.get_profile(s, TOKEN)
assert p.doctor_id == anonymous_doctor() and p.modules == ["veteran"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, TOKEN) is None
async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncClient) -> None:
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 404
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="own-doctor")
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 200
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "own-doctor"
async def test_screening_capability_respects_release_gate(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": False}
monkeypatch.setenv("INTAKE_DEMO", "1")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": True}
async def test_anonymous_contact_request_reaches_doctor(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
for event in [
{"type": "start"},
{"type": "button", "button": "consent:yes"},
{"type": "button", "button": "age:adult"},
{"type": "button", "button": "menu:contact_doctor"},
]:
response = await client.post(f"/api/v1/dialog/{token}/events", json=event)
assert response.status_code == 200
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
assert items[0]["last_event"] == "Пациент просит врача связаться"
card = (await client.get(f"/api/v1/patients/{token}", params={"doctor_id": anonymous_doctor()})).json()
assert card["patient"]["last_event"] == items[0]["last_event"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, token) is None
+3 -1
View File
@@ -43,7 +43,9 @@ const post = <T>(path: string, body?: unknown) =>
export const api = { export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }), signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }), signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: (focus: Focus) => post<AuthResult>("/auth/anonymous", { focus }), signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
patientCapabilities: () => request<{ primary_screening: boolean }>("/patient/capabilities"),
connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`),
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"), demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"), crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"), catalog: () => request<Catalog>("/instruments/catalog"),
+17 -15
View File
@@ -52,7 +52,7 @@ export const ru = {
or: "или", or: "или",
anonymous: { anonymous: {
title: "Войти анонимно", title: "Войти анонимно",
text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.", text: "Без имени, СНИЛС и полиса. Первичный опрос и связь с врачом — по обезличенному профилю.",
}, },
disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.", disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
}, },
@@ -71,12 +71,12 @@ export const ru = {
demoDay: (day: number) => `${day}-й день терапии`, demoDay: (day: number) => `${day}-й день терапии`,
}, },
anonymous: { anonymous: {
title: "Что вы хотите проверить?", title: "Помощь без личных данных",
note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.", note: "Врач видит ваши ответы и результаты под условным номером, без имени, СНИЛС и полиса. Ответы хранятся на сервере, ключ доступа к профилю — на этом устройстве.",
focus: { start: "Начать первичный опрос",
depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" }, continue: "Продолжить без личных данных",
ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" }, lead: "Пройдите единый первичный опрос: заранее выбирать предполагаемый диагноз не нужно.",
}, unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.",
submitting: "Создаём профиль…", submitting: "Создаём профиль…",
}, },
today: { today: {
@@ -96,11 +96,11 @@ export const ru = {
visit: "Следующий визит к врачу", visit: "Следующий визит к врачу",
medQuestion: "Вопрос о лекарстве", medQuestion: "Вопрос о лекарстве",
medQuestionHint: "Ответ по официальной инструкции", medQuestionHint: "Ответ по официальной инструкции",
messageDoctor: "Сообщение врачу", messageDoctor: "Связаться с врачом",
messageDoctorHint: "Врач ответит в рабочее время", messageDoctorHint: "Запрос появится в кабинете врача",
week: "Приём на этой неделе", week: "Приём на этой неделе",
weekTaken: (taken: number, total: number) => (total ? `Принято ${taken} из ${total}` : "Отметок пока нет"), weekTaken: (taken: number, total: number) => (total ? `Принято ${taken} из ${total}` : "Отметок пока нет"),
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.", noDoctor: "Врач пока не подключён. Подключите его без личных данных, чтобы передавать ответы и получать сопровождение.",
newMessage: "Новое сообщение", newMessage: "Новое сообщение",
open: "Открыть", open: "Открыть",
continue: "Продолжить", continue: "Продолжить",
@@ -127,7 +127,7 @@ export const ru = {
tablet: "1 приём", tablet: "1 приём",
week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"], week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.", onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.",
empty: "Назначения появятся здесь после входа через ЕМИАС.", empty: "Здесь появятся назначения вашего врача.",
question: "Вопрос о лекарстве", question: "Вопрос о лекарстве",
reminderTitle: "Время принять препарат", reminderTitle: "Время принять препарат",
reminderLead: "По назначению врача", reminderLead: "По назначению врача",
@@ -166,11 +166,13 @@ export const ru = {
visit: "Следующий визит", visit: "Следующий визит",
visitNone: "Дата следующего визита пока не назначена", visitNone: "Дата следующего визита пока не назначена",
attending: "Ваш лечащий врач", attending: "Ваш лечащий врач",
write: "Написать врачу", connect: "Подключить врача без личных данных",
writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.", connecting: "Подключаем врача…",
write: "Попросить врача связаться",
writeNote: "Запрос на связь появится в кабинете врача. Если срочно — 112.",
earlier: "Записаться к врачу раньше", earlier: "Записаться к врачу раньше",
plan: "Мой план безопасности", plan: "Мой план безопасности",
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.", noDoctor: "Врач пока не подключён. Можно подключить его без имени, СНИЛС и полиса: он увидит обезличенные ответы и результаты.",
}, },
profile: { profile: {
title: "Профиль", title: "Профиль",
@@ -185,7 +187,7 @@ export const ru = {
revokeConfirm: "Да, отозвать", revokeConfirm: "Да, отозвать",
cancel: "Отмена", cancel: "Отмена",
wipe: "Удалить данные с устройства", wipe: "Удалить данные с устройства",
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.", wipeNote: "Удалятся ключ доступа и локальная копия переписки. Обезличенные ответы останутся на сервере; без ключа восстановить доступ к этому профилю не получится.",
logout: "Выйти", logout: "Выйти",
messages: "Сообщения", messages: "Сообщения",
result: "Предварительный результат", result: "Предварительный результат",
+23 -30
View File
@@ -1,31 +1,35 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Focus } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell"; import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon"; import { Icon } from "../components/Icon";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext"; import { useSession } from "../session/SessionContext";
const FOCUS: { id: Focus; icon: IconName }[] = [ // Anonymous means no identifying data, with the same screening and care path.
{ id: "depression", icon: "smile" },
{ id: "ptsd", icon: "heart" },
];
// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос
export function AnonymousSignIn() { export function AnonymousSignIn() {
const t = ru.anonymous; const t = ru.anonymous;
const navigate = useNavigate(); const navigate = useNavigate();
const { signIn } = useSession(); const { signIn } = useSession();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [screening, setScreening] = useState<boolean | null>(null);
const start = async (focus: Focus) => { useEffect(() => {
let alive = true;
api.patientCapabilities()
.then((v) => { if (alive) setScreening(v.primary_screening); })
.catch(() => { if (alive) setError(true); });
return () => { alive = false; };
}, []);
const start = async () => {
setBusy(true); setBusy(true);
setError(false); setError(false);
try { try {
signIn(await api.signInAnonymous(focus)); // No self-selected diagnosis: the common intake determines the follow-up modules.
signIn(await api.signInAnonymous());
navigate("/flow", { replace: true }); navigate("/flow", { replace: true });
} catch { } catch {
setError(true); setError(true);
@@ -38,27 +42,16 @@ export function AnonymousSignIn() {
<div className="focus"> <div className="focus">
<TopBar onBack={() => navigate("/")} /> <TopBar onBack={() => navigate("/")} />
<div className="stack signin"> <div className="stack signin">
<span className="tile-icon tile-icon--teal"><Icon name="lock" size={26} /></span>
<h1 className="h1">{t.title}</h1> <h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
<div className="entry-grid"> <Notice>{t.note}</Notice>
{FOCUS.map(({ id, icon }) => ( {screening === false && <Notice>{t.unavailable}</Notice>}
<button key={id} type="button" className="entry" onClick={() => start(id)} disabled={busy}>
<span className="entry__top">
<span className="tile-icon">
<Icon name={icon} size={26} />
</span>
</span>
<span className="entry__title">
{t.focus[id].title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.focus[id].text}</span>
</button>
))}
</div>
{error && <Notice tone="danger">{ru.common.networkError}</Notice>} {error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<p className="muted small">{busy ? t.submitting : t.note}</p> <button type="button" className="btn btn--primary" disabled={busy} onClick={() => void start()}>
{busy ? t.submitting : screening ? t.start : t.continue}
<Icon name="chevronRight" size={20} />
</button>
</div> </div>
</div> </div>
</AppShell> </AppShell>
+27 -3
View File
@@ -1,3 +1,6 @@
import { useState } from "react";
import { api } from "../api/client";
import { useSession } from "../session/SessionContext";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AppShell, Notice } from "../components/AppShell"; import { AppShell, Notice } from "../components/AppShell";
@@ -11,7 +14,22 @@ export function Doctor() {
const t = ru.doctor; const t = ru.doctor;
const navigate = useNavigate(); const navigate = useNavigate();
const dialog = useDialog(); const dialog = useDialog();
const { plan } = useToday(); const { plan, failed } = useToday();
const { session } = useSession();
const [connecting, setConnecting] = useState(false);
const [connectFailed, setConnectFailed] = useState(false);
const connect = async () => {
if (!session || connecting) return;
setConnecting(true);
setConnectFailed(false);
try {
await api.connectDoctor(session.token);
navigate("/today");
} catch {
setConnectFailed(true);
setConnecting(false);
}
};
const go = (button: string, label: string) => const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow")); void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
@@ -29,8 +47,14 @@ export function Doctor() {
<h1 className="h1">{t.title}</h1> <h1 className="h1">{t.title}</h1>
</header> </header>
{plan && !plan.has_doctor ? ( {(failed || connectFailed) && <Notice tone="danger">{ru.common.networkError}</Notice>}
<Notice>{t.noDoctor}</Notice> {!plan ? (!failed && <div className="loader" aria-label="Загрузка" />) : !plan.has_doctor ? (
<div className="stack stack--sm">
<Notice>{t.noDoctor}</Notice>
<button type="button" className="btn btn--primary" disabled={connecting} onClick={() => void connect()}>
{connecting ? t.connecting : t.connect}
</button>
</div>
) : ( ) : (
<div className="cols"> <div className="cols">
<section className="card-list" aria-label={t.visit}> <section className="card-list" aria-label={t.visit}>
+7 -4
View File
@@ -7,7 +7,6 @@ import { useCatalog } from "../content/reference";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext"; import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step"; import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { WeekDots } from "./Meds"; import { WeekDots } from "./Meds";
import { humanDate, useToday } from "./useToday"; import { humanDate, useToday } from "./useToday";
@@ -84,7 +83,6 @@ function WeekCard({ rx, onOpen }: { rx: TodayPrescription; onOpen: () => void })
export function Today() { export function Today() {
const t = ru.today; const t = ru.today;
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useSession();
const dialog = useDialog(); const dialog = useDialog();
const { plan, failed } = useToday(); const { plan, failed } = useToday();
const catalog = useCatalog(); const catalog = useCatalog();
@@ -119,7 +117,7 @@ export function Today() {
!diaryDue && !diaryDue &&
q?.intake !== "review"; q?.intake !== "review";
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0]; const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
const noDoctor = session?.mode === "anonymous" || (plan !== null && !plan.has_doctor); const noDoctor = plan !== null && !plan.has_doctor;
const now = plan?.now ?? "00:00"; const now = plan?.now ?? "00:00";
const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`); const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`);
@@ -292,7 +290,12 @@ export function Today() {
{plan?.prescriptions[0] && <WeekCard rx={plan.prescriptions[0]} onOpen={() => navigate("/meds")} />} {plan?.prescriptions[0] && <WeekCard rx={plan.prescriptions[0]} onOpen={() => navigate("/meds")} />}
{noDoctor ? ( {noDoctor ? (
<Notice>{t.noDoctor}</Notice> <div className="stack stack--sm">
<Notice>{t.noDoctor}</Notice>
<button type="button" className="btn btn--primary" onClick={() => navigate("/doctor")}>
{ru.doctor.connect}
</button>
</div>
) : ( ) : (
<div className="quick"> <div className="quick">
<button <button