From 7a0590d4683b8cc9892870401e0ce75056642fe3 Mon Sep 17 00:00:00 2001 From: Dae Hyeon Kim Date: Mon, 27 Jul 2026 13:15:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(web,api,mobile):=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20DGX=20=EB=B0=B0=ED=8F=AC=20=ED=98=95?= =?UTF-8?q?=EC=83=81=20+=20=EC=8B=A4=ED=99=98=EC=9E=90=20org=20=EA=B0=80?= =?UTF-8?q?=EC=8B=9C=ED=99=94(ADR-051)=20+=20=EC=A0=84=EB=8B=AC=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=84=9C=EB=B2=84=EC=83=81=ED=83=9C=20+=20Metro=20?= =?UTF-8?q?blockList(BUG-093)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web: DGX 배포용 Dockerfile 신규, next.config/lib/config/lib/auth 배포 형상 정합, .env.example 신규 - infra: docker-compose.dgx.yml에 web 서비스 반영 - api(G3, ADR-051): core/config.py + api/v1/auth.py 실환자 org 가시화 반영, .env.example 갱신, test_auth_register.py 신규(qa 게이트 green) - api/mobile(G2): sessions.py 트렌드 데이터 계보 정리, mobile lib/api.ts + report/detail.tsx + records.tsx 전달버튼 서버상태 반영 - mobile(BUG-093): metro.config.js blockList 추가 - docs: patient_org_visibility_decision.md, trend_data_lineage_decision.md 신규(ADR-051 결정 문서) --- apps/api/.env.example | 5 + apps/api/src/api/v1/auth.py | 9 +- apps/api/src/api/v1/sessions.py | 9 ++ apps/api/src/core/config.py | 24 +++ apps/api/tests/test_auth_register.py | 145 +++++++++++++++++++ apps/mobile/app/(patient)/(tabs)/records.tsx | 7 +- apps/mobile/app/(patient)/report/detail.tsx | 32 ++-- apps/mobile/lib/api.ts | 4 + apps/mobile/metro.config.js | 23 +++ apps/web/.env.example | 18 +++ apps/web/Dockerfile | 88 +++++++++++ apps/web/lib/auth.ts | 9 +- apps/web/lib/config.ts | 26 +++- apps/web/next.config.ts | 12 ++ docs/ai/patient_org_visibility_decision.md | 90 ++++++++++++ docs/ai/trend_data_lineage_decision.md | 105 ++++++++++++++ infra/deploy/docker-compose.dgx.yml | 51 ++++++- 17 files changed, 635 insertions(+), 22 deletions(-) create mode 100644 apps/web/.env.example create mode 100644 apps/web/Dockerfile create mode 100644 docs/ai/patient_org_visibility_decision.md create mode 100644 docs/ai/trend_data_lineage_decision.md diff --git a/apps/api/.env.example b/apps/api/.env.example index 6804090..5a9679e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -23,6 +23,11 @@ ARGON2_PARALLELISM=4 # Age policy MINOR_AGE_CUTOFF=14 +# G3 — fallback org for app signups that don't send targetHospitalId (every +# real signup does not today). Unset = NULL (patient excluded from every +# clinician org-scope dashboard until admin-assigned). See core/config.py. +DEFAULT_TARGET_HOSPITAL_ID= + # CORS — Demo permissive; Phase 2 restricts to known origins CORS_ORIGINS=["*"] diff --git a/apps/api/src/api/v1/auth.py b/apps/api/src/api/v1/auth.py index c1d39f8..6e7e62b 100644 --- a/apps/api/src/api/v1/auth.py +++ b/apps/api/src/api/v1/auth.py @@ -238,7 +238,14 @@ async def register( aad=_profile_aad(user.id, "emergency_contact"), settings=settings, ), - target_hospital_id=payload.target_hospital_id, + # G3 fix: request value always wins; otherwise fall back to the + # operator-configured default org (settings.default_target_hospital_id, + # None by default = prior NULL behavior). See config.py docstring. + target_hospital_id=( + payload.target_hospital_id + if payload.target_hospital_id is not None + else settings.default_target_hospital_id + ), # v3 수정 1 — 확장 인적사항(선택). 비-PII 범주형이라 평문 저장. marital_status=payload.marital_status, household_type=payload.household_type, diff --git a/apps/api/src/api/v1/sessions.py b/apps/api/src/api/v1/sessions.py index af63018..9d4917b 100644 --- a/apps/api/src/api/v1/sessions.py +++ b/apps/api/src/api/v1/sessions.py @@ -645,6 +645,15 @@ async def get_report_summary( "hasPdf": bool( isinstance(report.content, dict) and report.content.get("pdf_base64") ), + # G2 fix — server-side delivered state so the mobile "전달하기" + # button can key off it instead of a non-persisted zustand + # record (`state/records.ts`, no persist middleware). Mirrors + # POST /report/deliver's own idempotent delivered_at semantics + # (sessions.py `deliver_report`). + "delivered": report.delivered_at is not None, + "deliveredAt": ( + report.delivered_at.isoformat() if report.delivered_at else None + ), }, } diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index d4d3171..00139b6 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -11,6 +11,7 @@ import os from functools import lru_cache from typing import Literal +from uuid import UUID from pydantic import AliasChoices, Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -70,6 +71,29 @@ class Settings(BaseSettings): # Age policy — FR-027 (Phase 2): under-14 guardian consent enforced. minor_age_cutoff: int = Field(default=14) + # G3 — patient dashboard visibility default org assignment (docs/ai/ + # patient_org_visibility_decision.md 권고 ②). `PatientProfile. + # target_hospital_id`'s only production writer is `auth.register`; the + # mobile app currently never sends `targetHospitalId`, so every real + # signup lands NULL and is excluded from every clinician org-scope + # filter (services/clinician.py). This setting supplies a fallback org + # for registrations that omit the field. A request-supplied + # `targetHospitalId` always takes precedence. `None` (default) + # preserves the exact prior behavior (NULL) — no behavior change until + # an operator sets it. Org-scope security itself is unchanged: this is + # purely a default *value* for the same field, not a new access path. + default_target_hospital_id: UUID | None = Field( + default=None, + validation_alias=AliasChoices("DEFAULT_TARGET_HOSPITAL_ID"), + description=( + "Fallback PatientProfile.target_hospital_id for registrations " + "that omit targetHospitalId. Unset by default (NULL, prior " + "behavior). Malformed UUID strings fail Settings() construction " + "at startup (pydantic type validation) rather than silently " + "falling back." + ), + ) + # CORS — DEV permissive. Validator below forbids wildcard outside dev. cors_origins: list[str] = Field(default_factory=lambda: ["*"]) diff --git a/apps/api/tests/test_auth_register.py b/apps/api/tests/test_auth_register.py index 214b02f..d706853 100644 --- a/apps/api/tests/test_auth_register.py +++ b/apps/api/tests/test_auth_register.py @@ -2,11 +2,17 @@ from __future__ import annotations +import uuid from datetime import datetime from typing import Any import pytest +from src.core.config import get_settings +from src.core.security import create_token, hash_password +from src.models.patient_profile import PatientProfile +from src.models.user import Organization, User + ADULT_BIRTH_YEAR = datetime.now().year - 30 # 30yo MINOR_BIRTH_YEAR = datetime.now().year - 10 # 10yo @@ -153,3 +159,142 @@ async def test_update_profile_requires_patient_auth(client): """인증 없이는 401.""" res = client.patch("/api/v1/auth/me/profile", json={"religion": "none"}) assert res.status_code == 401 + + +# ────────── G3 — DEFAULT_TARGET_HOSPITAL_ID fallback org (docs/ai/ +# patient_org_visibility_decision.md 권고 ②). Matrix: setting present/absent +# × request field present/absent. ────────── + + +async def _patient_target_hospital_id(db_session, user_id): + from sqlalchemy import select + + row = await db_session.execute( + select(PatientProfile.target_hospital_id).where( + PatientProfile.user_id == user_id + ) + ) + return row.scalar_one() + + +@pytest.mark.asyncio +async def test_register_no_default_no_request_field_stays_null( + client, db_session, test_settings +): + """Setting unset + request omits targetHospitalId -> NULL (prior behavior, + no regression).""" + assert test_settings.default_target_hospital_id is None + reg = client.post( + REGISTER_URL, json=_base_payload(email="g3-no-default-no-req@example.com") + ) + assert reg.status_code == 201, reg.json() + user_id = reg.json()["data"]["userId"] + assert await _patient_target_hospital_id(db_session, user_id) is None + + +@pytest.mark.asyncio +async def test_register_no_default_request_field_wins(client, db_session, test_settings): + """Setting unset + request supplies targetHospitalId -> request value used.""" + org_id = uuid.uuid4() + reg = client.post( + REGISTER_URL, + json=_base_payload( + email="g3-no-default-req@example.com", targetHospitalId=str(org_id) + ), + ) + assert reg.status_code == 201, reg.json() + user_id = reg.json()["data"]["userId"] + assert await _patient_target_hospital_id(db_session, user_id) == org_id + + +@pytest.mark.asyncio +async def test_register_default_set_no_request_field_assigns_default( + client, db_session, test_settings +): + """Setting present + request omits targetHospitalId -> default org assigned + (the G3 fix — this is the app-signup path that previously always landed + NULL and was excluded from every clinician org-scope filter).""" + org_id = uuid.uuid4() + settings_with_default = test_settings.model_copy( + update={"default_target_hospital_id": org_id} + ) + client.app.dependency_overrides[get_settings] = lambda: settings_with_default + try: + reg = client.post( + REGISTER_URL, json=_base_payload(email="g3-default-no-req@example.com") + ) + assert reg.status_code == 201, reg.json() + user_id = reg.json()["data"]["userId"] + assert await _patient_target_hospital_id(db_session, user_id) == org_id + finally: + client.app.dependency_overrides[get_settings] = lambda: test_settings + + +@pytest.mark.asyncio +async def test_register_default_set_request_field_wins(client, db_session, test_settings): + """Setting present + request supplies its own targetHospitalId -> request + value wins over the configured default.""" + default_org_id = uuid.uuid4() + request_org_id = uuid.uuid4() + settings_with_default = test_settings.model_copy( + update={"default_target_hospital_id": default_org_id} + ) + client.app.dependency_overrides[get_settings] = lambda: settings_with_default + try: + reg = client.post( + REGISTER_URL, + json=_base_payload( + email="g3-default-req@example.com", targetHospitalId=str(request_org_id) + ), + ) + assert reg.status_code == 201, reg.json() + user_id = reg.json()["data"]["userId"] + assigned = await _patient_target_hospital_id(db_session, user_id) + assert assigned == request_org_id + assert assigned != default_org_id + finally: + client.app.dependency_overrides[get_settings] = lambda: test_settings + + +@pytest.mark.asyncio +async def test_register_default_assigned_patient_passes_clinician_org_filter( + client, db_session, test_settings +): + """End-to-end G3 check: a patient who signs up through the app-style + request (no targetHospitalId) with DEFAULT_TARGET_HOSPITAL_ID configured + becomes visible on that org's clinician dashboard — the exact gap this + fix closes (services/clinician.py org-scope filter, `_patient_list_ + filter`).""" + org = Organization(name="G3 Test Hospital", type="hospital") + db_session.add(org) + await db_session.flush() + org_id = org.id + + settings_with_default = test_settings.model_copy( + update={"default_target_hospital_id": org_id} + ) + client.app.dependency_overrides[get_settings] = lambda: settings_with_default + try: + reg = client.post( + REGISTER_URL, json=_base_payload(email="g3-e2e-patient@example.com") + ) + assert reg.status_code == 201, reg.json() + finally: + client.app.dependency_overrides[get_settings] = lambda: test_settings + + clinician = User( + email=f"g3-doc-{uuid.uuid4().hex[:8]}@hospital.example", + password_hash=hash_password("Doctor!Password-2026", test_settings), + role="clinician", + organization_id=org_id, + ) + db_session.add(clinician) + await db_session.flush() + token = create_token(clinician.id, "access", role="clinician", settings=test_settings) + + res = client.get( + "/api/v1/clinician/patients", headers={"Authorization": f"Bearer {token}"} + ) + assert res.status_code == 200, res.json() + emails = [p["email"] for p in res.json()["data"]] + assert "g3-e2e-patient@example.com" in emails diff --git a/apps/mobile/app/(patient)/(tabs)/records.tsx b/apps/mobile/app/(patient)/(tabs)/records.tsx index 4425ead..78df46a 100644 --- a/apps/mobile/app/(patient)/(tabs)/records.tsx +++ b/apps/mobile/app/(patient)/(tabs)/records.tsx @@ -7,9 +7,10 @@ * * MOCK=0: `GET /api/v1/sessions`(본인 세션 목록 — id/status/createdAt/ * progress/리포트 유무) 실호출로 교체한다. 서버가 내려주는 필드가 세션 - * 요약뿐(문진 점수·중증도는 없음)이라 렌더링은 목업 피드보다 얇다 — 문진 - * 결과 상세(`/report/detail`)는 여전히 `state/records.ts` 스토어만 바라보는 - * mock 전용 화면이라(§6-B 배포 전) 실모드 행은 상세로 연결하지 않는다. + * 요약뿐(문진 점수·중증도는 없음)이라 렌더링은 목업 피드보다 얇다 — 리포트가 + * 있는 세션(`hasReport`)만 `sessionId`로 상세(`/report/detail`)로 연결한다 + * (G2 — 상세 화면이 서버 조회 경로를 지원한 이후 현행화; 서버 리포트 요약 + * 조회는 `state/records.ts` 로컬 스토어 없이도 동작한다). */ import { router } from "expo-router"; diff --git a/apps/mobile/app/(patient)/report/detail.tsx b/apps/mobile/app/(patient)/report/detail.tsx index 8599074..9fb10b3 100644 --- a/apps/mobile/app/(patient)/report/detail.tsx +++ b/apps/mobile/app/(patient)/report/detail.tsx @@ -5,9 +5,11 @@ * 가능, v3 원칙 1 가드레일)와 Handoff 리포트 미니 프리뷰를 보여준다. * AI 추정 질환명·도메인은 어디에도 표시하지 않는다. * - * [전달하기] (FR-047): 실백엔드는 §6-B(보관→전달) 배포 전이므로 실모드에서는 - * 버튼을 비노출한다(PRD §3.2 이중 전달 방지 가드). MOCK 모드에서만 로컬 상태 - * 전이(보관→전달됨)로 플로우를 시연한다. + * [전달하기] (FR-047): §6-B(보관→전달)는 배포되어 있다 — LIVE 모드는 서버 + * 리포트 요약(`report/summary`)의 `delivered`/`ready` 상태로 노출을 결정한다 + * (로컬 zustand 레코드는 persist가 없어 재실행·기록탭 경로에서 사라지므로 + * 노출 판단 기준으로 쓰지 않는다 — `state/records.ts` 참고). MOCK 모드는 + * 여전히 로컬 상태 전이(보관→전달됨)로 플로우를 시연한다. */ import * as FileSystem from "expo-file-system/legacy"; @@ -86,12 +88,13 @@ export default function ReportDetailScreen() { }; }, [accessToken, effectiveSessionId]); - // FR-047 · §6-B — 수동 전달. MOCK은 로컬 상태만 전이, 실모드는 서버에 전달 후 - // 로컬 반영. 실모드는 세션 id가 있어야 전달 대상이 특정된다. + // FR-047 · §6-B — 수동 전달. MOCK은 로컬 상태만 전이. 실모드는 서버에 전달 후 + // 서버 상태(summary.delivered)를 갱신한다 — 로컬 레코드(record)가 없는 + // 기록탭 경로(sessionId만 전달)에서도 동작해야 한다. const [delivering, setDelivering] = useState(false); const onDeliver = async () => { - if (!record) return; if (MOCK) { + if (!record) return; markDelivered(record.id); return; } @@ -99,7 +102,8 @@ export default function ReportDetailScreen() { setDelivering(true); try { await deliverReport(accessToken, effectiveSessionId); - markDelivered(record.id); + if (record) markDelivered(record.id); + setSummary((s) => (s ? { ...s, delivered: true } : s)); } catch (e) { Alert.alert( "전달하지 못했어요", @@ -276,16 +280,22 @@ export default function ReportDetailScreen() { - {/* FR-047 · §6-B — 보관 중인 리포트를 [전달하기]. 로컬 레코드(방금 제출한 - 인테이크)가 보관 상태일 때만 노출. 과거 기록 서버조회 열람은 읽기 전용. */} - {record && record.status === "stored" && (MOCK || effectiveSessionId) ? ( + {/* FR-047 · §6-B — 보관 중인 리포트를 [전달하기]. MOCK은 로컬 레코드 + 상태로, LIVE는 서버 요약(summary.ready && !summary.delivered)으로 + 노출을 판단한다 — 로컬 레코드가 없는 기록탭 경로(sessionId만 + 전달)에서도 동작해야 이중 전달 방지 가드가 유지된다. */} + {( + MOCK + ? record && record.status === "stored" + : effectiveSessionId && summary?.ready && !summary?.delivered + ) ? (