release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
11 changed files with 218 additions and 68 deletions
Showing only changes of commit 24e8ba4e72 - Show all commits
+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
+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>
+26 -2
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>}
{!plan ? (!failed && <div className="loader" aria-label="Загрузка" />) : !plan.has_doctor ? (
<div className="stack stack--sm">
<Notice>{t.noDoctor}</Notice> <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}>
+6 -3
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 ? (
<div className="stack stack--sm">
<Notice>{t.noDoctor}</Notice> <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