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
+ ) ? (