Compare commits

..
Author SHA1 Message Date
ElebobaandClaude Opus 5.5 267fe24cbc chore(devops): env sync to server + SERVICE_ENV secret for CD (parked, not merged)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:30:42 +03:00
Eleboba 683a74b004 Merge pull request 'chore(devops): CD — автодеплой dev на сервер' (#98) from chore/cd-deploy into dev
CD / ruff · pytest (push) Successful in 2m21s
CI / ruff · pytest (push) Successful in 2m30s
CD / deploy dev (push) Successful in 1m27s
2026-10-04 16:56:12 +03:00
ElebobaandClaude Opus 5.5 285c95ac6b chore(devops): CD — every merge into dev deploys dev.meditron.tenvex.ru
CI / ruff · pytest (pull_request) Successful in 2m24s
Gitea Actions: lint + tests, then infra/deploy/deploy.sh over ssh as the dedicated ptsr-deploy user
(secrets DEPLOY_SSH_KEY, DEPLOY_HOST), then an HTTPS smoke check. deploy.sh now ships a tar.gz via scp and
retries every remote step (the host drops some ssh connections); the web build runs as the deploy user so the
next release can clean up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:53:35 +03:00
Eleboba 24e8ba4e72 Merge pull request 'fix: анонимная помощь с первичным опросом и доступом к врачу' (#97) from feat/95-anonymous-care into dev
CI / ruff · pytest (push) Successful in 2m34s
Reviewed-on: #97
2026-10-04 16:51:23 +03:00
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
19 changed files with 540 additions and 78 deletions
+55
View File
@@ -0,0 +1,55 @@
name: CD
# Every merge into dev → lint + tests → deploy to the shared server (dev.meditron.tenvex.ru / meditron.tenvex.ru).
# Secrets: DEPLOY_SSH_KEY (private key of the `ptsr-deploy` user), DEPLOY_HOST (user@host).
on:
push:
branches: [dev]
workflow_dispatch:
concurrency:
group: deploy-dev
cancel-in-progress: false
jobs:
test:
name: ruff · pytest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: "0.8.4"
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run pytest -q
deploy:
name: deploy dev
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ssh key
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "${{ secrets.SERVICE_ENV }}" > ~/service.env
chmod 600 ~/service.env
- name: deploy
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_SSH_OPTS: "-i /root/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
run: |
export DEPLOY_SSH_OPTS="${DEPLOY_SSH_OPTS//\/root\/.ssh/$HOME/.ssh}"
export SERVICE_ENV_FILE="$HOME/service.env"
infra/deploy/deploy.sh HEAD
- name: smoke
run: |
sleep 10
for u in https://dev.meditron.tenvex.ru/ https://dev.meditron.tenvex.ru/api/v1/demo/personas https://dev.meditron.tenvex.ru/doctor/; do
code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 "$u"); echo "$u -> $code"; [ "$code" = 200 ]
done
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install up down logs seed demo-reset lint fmt test .PHONY: install up down logs seed demo-reset lint fmt test env-sync deploy
install: install:
uv sync uv sync
@@ -30,3 +30,9 @@ fmt:
test: test:
uv run pytest -q uv run pytest -q
env-sync:
infra/deploy/env-sync.sh .env
deploy:
infra/deploy/deploy.sh origin/dev
+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
+26 -6
View File
@@ -1,24 +1,44 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Deploy origin/dev to the shared server (ssh alias `vobot`, dir /srv/ptsr). Run from a clean checkout: # Deploy a git ref to the shared server (dir /srv/ptsr). Used by hand and by CD (.gitea/workflows/deploy.yml).
# infra/deploy/deploy.sh [git-ref] default: origin/dev # infra/deploy/deploy.sh [git-ref] default: origin/dev
# Env: DEPLOY_HOST (ssh host/alias, default `vobot`), DEPLOY_SSH_OPTS (extra ssh/scp options).
# Keeps /srv/ptsr/.env (secrets live only on the server) and the Postgres volume. # Keeps /srv/ptsr/.env (secrets live only on the server) and the Postgres volume.
# The host drops some ssh connections (rate limiting) — every remote step is retried.
set -euo pipefail set -euo pipefail
REF=${1:-origin/dev} REF=${1:-origin/dev}
HOST=${DEPLOY_HOST:-vobot} HOST=${DEPLOY_HOST:-vobot}
OPTS=${DEPLOY_SSH_OPTS:-}
DIR=/srv/ptsr DIR=/srv/ptsr
git fetch -q origin || true git fetch -q origin 2>/dev/null || true
SHA=$(git rev-parse --short "$REF") SHA=$(git rev-parse --short "$REF")
TGZ=$(mktemp --suffix=.tgz)
trap 'rm -f "$TGZ"' EXIT
git archive --format=tar.gz -o "$TGZ" "$REF"
retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; echo "retry $n: $1" >&2; sleep $((n * 10)); done; }
echo "deploy $REF ($SHA) -> $HOST:$DIR" echo "deploy $REF ($SHA) -> $HOST:$DIR"
git archive --format=tar "$REF" | ssh "$HOST" "rm -rf $DIR/app.new && mkdir -p $DIR/app.new && tar -x -C $DIR/app.new" # shellcheck disable=SC2086
ssh "$HOST" bash -s <<SH retry scp -q $OPTS "$TGZ" "$HOST:$DIR/release.tgz"
# Service variables managed via the SERVICE_ENV secret (infra/deploy/env-sync.sh) — merged before the restart.
if [ -n "${SERVICE_ENV_FILE:-}" ] && [ -s "$SERVICE_ENV_FILE" ]; then
# shellcheck disable=SC2086
retry scp -q $OPTS "$SERVICE_ENV_FILE" "$HOST:$DIR/service.env"
fi
# shellcheck disable=SC2086
retry ssh $OPTS "$HOST" "bash -s" <<SH
set -euo pipefail set -euo pipefail
cd $DIR cd $DIR
rm -rf app.new && mkdir app.new && tar -xzf release.tgz -C app.new && rm -f release.tgz
cp app.new/infra/deploy/env_merge.py env_merge.py
if [ -s service.env ]; then rc=0; python3 env_merge.py .env service.env || rc=\$?; rm -f service.env; [ "\$rc" = 0 ] || [ "\$rc" = 3 ]; fi
cp .env app.new/.env cp .env app.new/.env
rm -rf app.old; [ -d app ] && mv app app.old; mv app.new app rm -rf app.old; [ -d app ] && mv app app.old; mv app.new app
chmod 755 $DIR
cd app cd app
echo "$SHA" > REVISION echo "$SHA" > REVISION
docker run --rm -v "\$PWD/web:/w" -w /w node:20-alpine sh -c "npm ci --no-audit --no-fund >/dev/null && npm run build >/dev/null" docker run --rm --user "\$(id -u):\$(id -g)" -e HOME=/tmp -e npm_config_cache=/tmp/.npm -v "\$PWD/web:/w" -w /w node:20-alpine sh -c "npm ci --no-audit --no-fund >/dev/null && npm run build >/dev/null"
docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build </dev/null docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build </dev/null
for i in \$(seq 1 40); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 3; done for i in \$(seq 1 40); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 3; done
curl -sf 127.0.0.1:8010/health && echo " api ok" curl -sf 127.0.0.1:8010/health >/dev/null && echo "api ok @ $SHA"
SH SH
+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
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Sync service variables from the local .env to the server — now and for every future deploy.
# make env-sync (or infra/deploy/env-sync.sh [path/to/.env])
# 1. Filters the local env: drops GITEA_*, empty values and server-managed keys (DB passwords, PSEUDO_KEY, URLs).
# 2. Stores the result as the Gitea Actions secret SERVICE_ENV → CD merges it into /srv/ptsr/.env on each deploy.
# 3. Applies it on the server right away and recreates app + dashboard if anything changed.
# Values are never printed; only key names.
set -euo pipefail
SRC=${1:-.env}
HOST=${DEPLOY_HOST:-vobot}
OPTS=${DEPLOY_SSH_OPTS:-}
DIR=/srv/ptsr
HERE=$(cd "$(dirname "$0")" && pwd)
[ -f "$SRC" ] || { echo "no $SRC"; exit 1; }
# Values without inline comments / trailing spaces ("KEY=value # коммент" → "value").
val() { grep -E "^$1=" "$SRC" | tail -1 | cut -d= -f2- | sed -E 's/[[:space:]]+#.*$//; s/[[:space:]]+$//' || true; }
GITEA_URL=$(val GITEA_URL)
GITEA_REPO=$(val GITEA_REPO)
GITEA_TOKEN=$(val GITEA_TOKEN)
export GITEA_URL GITEA_REPO GITEA_TOKEN
OUT=$(mktemp)
trap 'rm -f "$OUT"' EXIT
sed -E 's/[[:space:]]+#.*$//; s/[[:space:]]+$//' "$SRC" \
| grep -E '^[A-Z_][A-Z0-9_]*=.+' \
| grep -vE '^(GITEA_[A-Z_]*|POSTGRES_PASSWORD|IDENTITY_DB_PASSWORD|CLINICAL_DB_PASSWORD|PSEUDO_KEY|PUBLIC_URL|IDENTITY_DB_URL|CLINICAL_DB_URL|API_BASE_URL)=' \
> "$OUT" || true
echo "keys: $(cut -d= -f1 "$OUT" | tr '\n' ' ')"
[ -s "$OUT" ] || { echo "nothing to sync"; exit 0; }
if [ -n "$GITEA_TOKEN" ] && [ -n "$GITEA_URL" ] && [ -n "$GITEA_REPO" ]; then
python3 - "$OUT" <<'PY'
import json, os, sys, urllib.request
url = f"{os.environ['GITEA_URL']}/api/v1/repos/{os.environ['GITEA_REPO']}/actions/secrets/SERVICE_ENV"
body = json.dumps({"data": open(sys.argv[1]).read()}).encode()
req = urllib.request.Request(url, method="PUT", data=body,
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}",
"Content-Type": "application/json"})
print("gitea secret SERVICE_ENV:", urllib.request.urlopen(req).status)
PY
else
echo "GITEA_* not set in $SRC — skipping the CD secret (server-only sync)"
fi
retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; sleep $((n * 10)); done; }
# shellcheck disable=SC2086
retry scp -q $OPTS "$OUT" "$HOST:$DIR/service.env"
# shellcheck disable=SC2086
retry scp -q $OPTS "$HERE/env_merge.py" "$HOST:$DIR/env_merge.py"
# shellcheck disable=SC2086
retry ssh $OPTS "$HOST" "bash -s" <<SH
set -euo pipefail
cd $DIR
rc=0; python3 env_merge.py .env service.env || rc=\$?
rm -f service.env
if [ "\$rc" = 0 ]; then
cp .env app/.env
cd app && docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --force-recreate app dashboard </dev/null 2>&1 | tail -2
for i in \$(seq 1 30); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 2; done
curl -sf 127.0.0.1:8010/health >/dev/null && echo "api ok"
elif [ "\$rc" != 3 ]; then
exit "\$rc"
fi
SH
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Merge service variables into the server env file (runs on the server, stdlib only).
env_merge.py TARGET SOURCE
Keys from SOURCE overwrite / add to TARGET; keys only in TARGET stay (server-generated secrets: DB passwords,
PSEUDO_KEY, PUBLIC_URL). Protected keys are never taken from SOURCE. Prints changed key NAMES only (no values).
Exit code: 0 = changed, 3 = nothing changed.
"""
import os
import sys
import tempfile
PROTECTED = {
"POSTGRES_PASSWORD",
"IDENTITY_DB_PASSWORD",
"CLINICAL_DB_PASSWORD",
"PSEUDO_KEY",
"PUBLIC_URL",
"IDENTITY_DB_URL",
"CLINICAL_DB_URL",
"API_BASE_URL",
}
def parse(path: str) -> list[tuple[str | None, str]]:
"""Lines as (key, raw_line); comments/blank lines have key None. Keeps order and comments."""
out: list[tuple[str | None, str]] = []
with open(path, encoding="utf-8") as f:
for raw in f.read().splitlines():
line = raw.strip()
key = line.split("=", 1)[0].strip() if line and not line.startswith("#") and "=" in line else None
out.append((key, raw))
return out
def merge(target: str, source: str) -> list[str]:
lines = parse(target)
incoming = {
k: raw
for k, raw in parse(source)
if k and k not in PROTECTED and not k.startswith("GITEA_") and raw.split("=", 1)[1].strip()
}
changed: list[str] = []
seen: set[str] = set()
for i, (k, raw) in enumerate(lines):
if k in incoming:
seen.add(k)
if raw != incoming[k]:
lines[i] = (k, incoming[k])
changed.append(k)
for k, raw in incoming.items():
if k not in seen:
lines.append((k, raw))
changed.append(k)
if changed:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(target)))
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write("\n".join(raw for _, raw in lines) + "\n")
st = os.stat(target)
os.chmod(tmp, 0o600)
if os.geteuid() == 0:
os.chown(tmp, st.st_uid, st.st_gid)
os.replace(tmp, target)
return changed
def main() -> int:
changed = merge(sys.argv[1], sys.argv[2])
if not changed:
print("env: no changes")
return 3
print("env: updated " + ", ".join(sorted(changed)))
return 0
if __name__ == "__main__":
sys.exit(main())
+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
+21
View File
@@ -0,0 +1,21 @@
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location("env_merge", Path("infra/deploy/env_merge.py"))
env_merge = importlib.util.module_from_spec(spec)
spec.loader.exec_module(env_merge)
def test_merge_updates_adds_and_protects(tmp_path):
target = tmp_path / "server.env"
target.write_text("# server\nPSEUDO_KEY=server-secret\nCHAT_AGENT=off\nTZ=Europe/Moscow\n")
source = tmp_path / "service.env"
source.write_text("CHAT_AGENT=gigachat\nINTAKE_DEMO=true\nPSEUDO_KEY=evil\nGITEA_TOKEN=x\nEMPTY=\n")
changed = env_merge.merge(str(target), str(source))
assert sorted(changed) == ["CHAT_AGENT", "INTAKE_DEMO"]
text = target.read_text()
assert "PSEUDO_KEY=server-secret" in text and "evil" not in text
assert "CHAT_AGENT=gigachat" in text and "INTAKE_DEMO=true" in text and "TZ=Europe/Moscow" in text
assert "GITEA_TOKEN" not in text and "EMPTY" not in text
assert text.startswith("# server\n")
assert env_merge.merge(str(target), str(source)) == [] # idempotent
+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