Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=["*"]

Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/api/v1/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/api/v1/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
},
}

Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: ["*"])

Expand Down
145 changes: 145 additions & 0 deletions apps/api/tests/test_auth_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
7 changes: 4 additions & 3 deletions apps/mobile/app/(patient)/(tabs)/records.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
32 changes: 21 additions & 11 deletions apps/mobile/app/(patient)/report/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -86,20 +88,22 @@ 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;
}
if (!accessToken || !effectiveSessionId) return;
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(
"전달하지 못했어요",
Expand Down Expand Up @@ -276,16 +280,22 @@ export default function ReportDetailScreen() {

<View style={{ flex: 1 }} />

{/* 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
) ? (
<Button
label="의료진에게 전달하기"
onPress={() => void onDeliver()}
loading={delivering}
/>
) : null}
{record && record.status === "delivered" ? (
{(MOCK ? record && record.status === "delivered" : effectiveSessionId && summary?.delivered) ? (
<Text style={styles.deliveredNote}>의료진에게 전달됐어요. 진료 때 함께 확인해요.</Text>
) : null}
</ScrollView>
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,10 @@ export type ReportSummary = {
selfReported?: ReportSelfReported[];
questionnaires?: ReportQuestionnaire[];
disclaimer?: string;
/** G2 — server-side delivered state (delivered_at != null). Drives the
* LIVE-mode "전달하기" button instead of the non-persisted local record. */
delivered?: boolean;
deliveredAt?: string | null;
};

/**
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// Learn more: https://docs.expo.dev/guides/monorepos/
const { getDefaultConfig } = require("expo/metro-config");
// metro-config 0.83's package.json "exports" only allows deep imports under
// "private/*"; the CJS build wraps the default export in an ESM interop
// object, so ".default" must be unwrapped explicitly.
const exclusionList = require("metro-config/private/defaults/exclusionList")
.default;
const path = require("path");

const projectRoot = __dirname;
Expand All @@ -16,4 +21,22 @@ config.resolver.nodeModulesPaths = [
path.resolve(workspaceRoot, "node_modules"),
];

// 3. Exclude non-code monorepo directories from the crawl. `experiments/`
// hosts per-run artifacts including live postgres unix domain sockets
// (e.g. pgdata_master/.s.PGSQL.5432); Metro's crawler open()s every
// discovered path as a plain file, which throws ENXIO on a socket and
// crashes @expo/cli's uncaughtException handler (rethrows for non
// EMFILE/darwin cases). `backups/` is excluded for the same class of risk
// (arbitrary non-code snapshots). `.git/` is already excluded by
// metro-file-map's built-in VCS_DIRECTORIES pattern, kept here as an
// explicit safety net.
const escapeStringRegexp = (str) =>
str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

config.resolver.blockList = exclusionList([
new RegExp(`^${escapeStringRegexp(workspaceRoot)}/experiments/.*$`),
new RegExp(`^${escapeStringRegexp(workspaceRoot)}/backups/.*$`),
new RegExp(`^${escapeStringRegexp(workspaceRoot)}/\\.git/.*$`),
]);

module.exports = config;
18 changes: 18 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Neuro-Sync clinician dashboard (Next.js) — environment template.
# Copy to `.env` and fill in. NEVER commit `.env` (gitignored).

# Backend API base URL, called server-side only (lib/api.ts). Local dev
# default matches apps/api's dev port (see apps/api/.env.example).
API_BASE_URL=http://localhost:8000

# Production deployments MUST use an https:// API_BASE_URL by default
# (lib/config.ts::assertProductionTLS). Set API_TLS_EXEMPT=1 ONLY for the
# closed-network DGX demo (infra/deploy/docker-compose.dgx.yml), where
# neither this app nor the api it calls have TLS in front of them anywhere
# on the network. Also relaxes the session cookie's `Secure` attribute
# (lib/auth.ts) — otherwise the browser would never send cookies back over
# the plain-http demo connection. Leave unset for any internet-reachable
# deployment.
API_TLS_EXEMPT=

NODE_ENV=production
Loading
Loading