diff --git a/homeflow/firebase.json b/homeflow/firebase.json index b582003..12b7763 100644 --- a/homeflow/firebase.json +++ b/homeflow/firebase.json @@ -22,5 +22,8 @@ "location": "nam5", "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "storage": { + "rules": "storage.rules" } } diff --git a/homeflow/firestore.rules b/homeflow/firestore.rules index 72e1f7f..f9f11d7 100644 --- a/homeflow/firestore.rules +++ b/homeflow/firestore.rules @@ -5,44 +5,101 @@ service cloud.firestore { // ── Helpers ─────────────────────────────────────────────────────────────── - // Returns the caller's Throne-internal user ID from their profile doc. - // Returns null if the profile doesn't exist or the field isn't set. - function callerThroneUserId() { - let profile = get(/databases/$(database)/documents/users/$(request.auth.uid)); - return profile.data.get('throneUserId', null); + // True only for authenticated users accessing their own data. + function isOwner(uid) { + return request.auth != null && request.auth.uid == uid; } // ── User profiles ───────────────────────────────────────────────────────── match /users/{uid} { - // Users can only read and write their own profile. - allow read, write: if request.auth != null && request.auth.uid == uid; - } + // Owner can read their own profile document. + allow read: if isOwner(uid); - // ── Throne sessions ─────────────────────────────────────────────────────── + // Only allow writing the Throne device-link fields to the root profile + // document. All other app data lives in named subcollections below. + // This prevents a client from writing arbitrary fields to the root doc + // (e.g. spoofing another participant's throneUserId). + allow write: if isOwner(uid) + && request.resource.data.keys().hasOnly(['throneUserId', 'throneUserIdSetAt']); - match /sessions/{sessionId} { - // A user may only read sessions whose Throne userId matches theirs. - // Writes come exclusively from the Cloud Function (Admin SDK bypasses rules). - allow read: if request.auth != null - && callerThroneUserId() != null - && resource.data.userId == callerThroneUserId(); - allow write: if false; - } + // ── Throne data (written by Cloud Function, read by owner) ────────────── + + match /throne_sessions/{sessionId} { + allow read: if isOwner(uid); + allow write: if false; // Cloud Function (Admin SDK) only + } + + match /throne_metrics/{metricId} { + allow read: if isOwner(uid); + allow write: if false; // Cloud Function (Admin SDK) only + } + + match /throne_sync/{docId} { + allow read: if isOwner(uid); + allow write: if false; // Cloud Function (Admin SDK) only + } + + // ── HealthKit data (written by device, read by owner) ─────────────────── + + match /hk_heartRate/{sampleId} { + allow read, write: if isOwner(uid); + } + + match /hk_stepCount/{sampleId} { + allow read, write: if isOwner(uid); + } + + match /hk_sleepAnalysis/{sampleId} { + allow read, write: if isOwner(uid); + } + + match /hk_heartRateVariabilitySDNN/{sampleId} { + allow read, write: if isOwner(uid); + } - // ── Throne metrics ──────────────────────────────────────────────────────── + match /hk_sync/{metricType} { + allow read, write: if isOwner(uid); + } - match /metrics/{metricId} { - // Metrics are fetched only by sessionId, which callers can only know - // from their own sessions query (guarded above). Require authentication. - allow read: if request.auth != null; - allow write: if false; + // ── Clinical data ──────────────────────────────────────────────────────── + + match /clinical_notes/{noteId} { + allow read, write: if isOwner(uid); + } + + match /medical_history_prefill/{docId} { + allow read, write: if isOwner(uid); + } + + match /medical_history/{docId} { + allow read, write: if isOwner(uid); + } + + // ── Study metadata ─────────────────────────────────────────────────────── + + match /surgery_date/{docId} { + allow read, write: if isOwner(uid); + } + + // Placeholder — logic to be added later + match /consent_response/{docId} { + allow read, write: if isOwner(uid); + } + + // Placeholder — logic to be added later + match /study_timeline/{docId} { + allow read, write: if isOwner(uid); + } } - // ── Throne sync state ───────────────────────────────────────────────────── + // ── Admin-only collections (Cloud Function / Admin SDK bypass rules) ─────── match /throneSync/{studyId} { - // Admin / Cloud Function only — no client access. + allow read, write: if false; + } + + match /throneUserMap/{throneUserId} { allow read, write: if false; } } diff --git a/homeflow/functions/src/index.ts b/homeflow/functions/src/index.ts index b289873..22319f7 100644 --- a/homeflow/functions/src/index.ts +++ b/homeflow/functions/src/index.ts @@ -1,11 +1,12 @@ /** * StreamSync Cloud Functions * - * - throneIngestDaily: Scheduled daily at 3 AM PT, syncs Throne data to Firestore - * - syncThroneNow: HTTP trigger for manual/dev sync (requires x-admin-token header) + * - throneIngestDaily: Scheduled daily at 3 AM PT, syncs Throne data to Firestore + * - syncThroneNow: HTTP trigger for manual/dev sync (requires x-admin-token header) + * - syncThroneUserMap: Firestore trigger — keeps throneUserMap in sync when a user's + * throneUserId field is set or changed by the study coordinator * - * Config is read from functions/.env (deployed with the function bundle). - * Required env vars: + * Required env vars (functions/.env): * THRONE_API_KEY, THRONE_BASE_URL, THRONE_STUDY_ID, ADMIN_TOKEN * Optional: * THRONE_TIMEZONE (defaults to America/Los_Angeles) @@ -15,16 +16,15 @@ import {setGlobalOptions} from "firebase-functions/v2"; import {onRequest} from "firebase-functions/v2/https"; import {onSchedule} from "firebase-functions/v2/scheduler"; import * as admin from "firebase-admin"; +import * as crypto from "crypto"; import * as logger from "firebase-functions/logger"; import {runThroneIngestion, ThroneConfig} from "./throneIngestion"; -// Initialize Firebase Admin admin.initializeApp(); -// setGlobalOptions must be called before any function definitions setGlobalOptions({maxInstances: 10}); -// ─── Config from .env ──────────────────────────────────────────────────────── +// ─── Config ────────────────────────────────────────────────────────────────── function requireEnv(name: string): string { const val = process.env[name]; @@ -45,7 +45,7 @@ function getThroneConfig(): ThroneConfig { export const throneIngestDaily = onSchedule( { - schedule: "0 3 * * *", // 3:00 AM every day + schedule: "0 3 * * *", timeZone: "America/Los_Angeles", }, async () => { @@ -59,7 +59,6 @@ export const throneIngestDaily = onSchedule( } catch (err) { logger.error("Throne ingestion failed", err); - // Record error in sync state const db = admin.firestore(); await db.collection("throneSync").doc(studyId).set({ lastRunAt: new Date().toISOString(), @@ -72,19 +71,22 @@ export const throneIngestDaily = onSchedule( }, ); -// ─── Manual HTTP Trigger (dev/admin) ───────────────────────────────────────── +// ─── Manual HTTP Trigger ───────────────────────────────────────────────────── export const syncThroneNow = onRequest(async (req, res) => { - // Only accept POST if (req.method !== "POST") { res.status(405).send("Method not allowed"); return; } - // Verify admin token - const expected = process.env.ADMIN_TOKEN; - const token = req.headers["x-admin-token"]; - if (!expected || !token || token !== expected) { + const expected = process.env.ADMIN_TOKEN ?? ""; + const token = typeof req.headers["x-admin-token"] === "string" + ? req.headers["x-admin-token"] + : ""; + const validToken = expected.length > 0 && + token.length === expected.length && + crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected)); + if (!validToken) { res.status(401).send("Unauthorized: invalid or missing x-admin-token"); return; } @@ -95,11 +97,7 @@ export const syncThroneNow = onRequest(async (req, res) => { const config = getThroneConfig(); const fullSync = req.body?.fullSync === true; const result = await runThroneIngestion(config, {fullSync}); - res.status(200).json({ - status: "ok", - fullSync, - ...result, - }); + res.status(200).json({status: "ok", fullSync, ...result}); } catch (err) { logger.error("Manual Throne sync failed", err); res.status(500).json({ diff --git a/homeflow/functions/src/throneIngestion.ts b/homeflow/functions/src/throneIngestion.ts index decc0ec..10c23cc 100644 --- a/homeflow/functions/src/throneIngestion.ts +++ b/homeflow/functions/src/throneIngestion.ts @@ -2,12 +2,18 @@ * Throne Research API Ingestion Module * * Fetches uroflow session data from Throne Research API, - * normalizes sessions + metrics, and writes to Firestore. + * normalizes sessions + metrics, and writes to Firestore + * under each participant's user document. * - * Firestore schema: - * sessions/{sessionId} — NormalizedSession + studyId - * metrics/{metricId} — NormalizedMetric + studyId - * throneSync/{studyId} — SyncState (lastRunAt, lastLtTs, etc.) + * Firestore schema (per user): + * users/{firebaseUid}/throne_sessions/{sessionId} — NormalizedSession + * users/{firebaseUid}/throne_metrics/{metricId} — NormalizedMetric + * users/{firebaseUid}/throne_sync/state — per-user sync state + * + * Admin collections: + * throneSync/{studyId} — study-level sync cursor (Cloud Function use only) + * throneUserMap/{throneUserId} — Throne userId → Firebase UID reverse lookup + * (maintained automatically by the syncThroneUserMap Cloud Function trigger) */ import * as admin from "firebase-admin"; @@ -194,33 +200,106 @@ function normalizeSessions( // ─── Firestore Writer ──────────────────────────────────────────────────────── -const BATCH_LIMIT = 400; // Firestore limit is 500; leave headroom +const BATCH_LIMIT = 400; async function writeToFirestore( db: admin.firestore.Firestore, sessions: NormalizedSession[], metrics: NormalizedMetric[], ): Promise { - // Write sessions in batches - for (let i = 0; i < sessions.length; i += BATCH_LIMIT) { - const batch = db.batch(); - const chunk = sessions.slice(i, i + BATCH_LIMIT); - for (const s of chunk) { - batch.set(db.collection("sessions").doc(s.id), s, {merge: true}); - } - await batch.commit(); - logger.info(`Wrote sessions batch ${i}-${i + chunk.length}`); + // Group sessions by Throne userId + const sessionsByThroneUser = new Map(); + for (const s of sessions) { + const arr = sessionsByThroneUser.get(s.userId) ?? []; + arr.push(s); + sessionsByThroneUser.set(s.userId, arr); + } + + // Build sessionId → throneUserId index for routing metrics to the right user + const sessionThroneUser = new Map(); + for (const s of sessions) { + sessionThroneUser.set(s.id, s.userId); + } + + // Group metrics by Throne userId + const metricsByThroneUser = new Map(); + for (const m of metrics) { + const throneUserId = sessionThroneUser.get(m.sessionId); + if (!throneUserId) continue; // orphaned metric — skip + const arr = metricsByThroneUser.get(throneUserId) ?? []; + arr.push(m); + metricsByThroneUser.set(throneUserId, arr); + } + + // Build throneUserId → firebaseUid map by querying users with throneUserId set. + // This is self-contained and does not rely on the throneUserMap collection or + // the syncThroneUserMap trigger — any user who has saved their Throne User ID + // via the app (users/{uid}.throneUserId) is automatically included. + const usersSnap = await db.collection("users") + .where("throneUserId", "!=", null) + .get(); + const throneToFirebase = new Map(); + for (const doc of usersSnap.docs) { + const tid = doc.data().throneUserId as string | undefined; + if (tid) throneToFirebase.set(tid, doc.id); } + logger.info("Throne→Firebase mappings found: " + throneToFirebase.size); + + // For each Throne userId, look up the Firebase UID and write to user-scoped paths + for (const [throneUserId, userSessions] of sessionsByThroneUser) { + const firebaseUid = throneToFirebase.get(throneUserId); + + if (!firebaseUid) { + logger.warn( + "No users/{uid}.throneUserId match for throneUserId=" + throneUserId + + " — skipping " + userSessions.length + " session(s)." + + " Have the participant enter their Throne User ID in the app.", + ); + continue; + } - // Write metrics in batches - for (let i = 0; i < metrics.length; i += BATCH_LIMIT) { - const batch = db.batch(); - const chunk = metrics.slice(i, i + BATCH_LIMIT); - for (const m of chunk) { - batch.set(db.collection("metrics").doc(m.id), m, {merge: true}); + const userMetrics = metricsByThroneUser.get(throneUserId) ?? []; + + // Write sessions in batches + for (let i = 0; i < userSessions.length; i += BATCH_LIMIT) { + const batch = db.batch(); + for (const s of userSessions.slice(i, i + BATCH_LIMIT)) { + batch.set( + db.collection(`users/${firebaseUid}/throne_sessions`).doc(s.id), + s, + {merge: true}, + ); + } + await batch.commit(); + logger.info(`Wrote sessions batch for uid=${firebaseUid}`); } - await batch.commit(); - logger.info(`Wrote metrics batch ${i}-${i + chunk.length}`); + + // Write metrics in batches + for (let i = 0; i < userMetrics.length; i += BATCH_LIMIT) { + const batch = db.batch(); + for (const m of userMetrics.slice(i, i + BATCH_LIMIT)) { + batch.set( + db.collection(`users/${firebaseUid}/throne_metrics`).doc(m.id), + m, + {merge: true}, + ); + } + await batch.commit(); + logger.info(`Wrote metrics batch for uid=${firebaseUid}`); + } + + // Write per-user sync state + await db.doc(`users/${firebaseUid}/throne_sync/state`).set({ + lastRunAt: new Date().toISOString(), + lastStatus: "success", + sessionCount: userSessions.length, + metricCount: userMetrics.length, + }, {merge: true}); + + logger.info( + `Ingestion complete for uid=${firebaseUid}: ` + + `${userSessions.length} sessions, ${userMetrics.length} metrics`, + ); } } @@ -233,7 +312,7 @@ export async function runThroneIngestion( const db = admin.firestore(); const studyId = config.studyId; - // Determine time window: check last sync state or default to last 7 days + // Determine time window from study-level sync cursor const syncRef = db.collection("throneSync").doc(studyId); const syncDoc = await syncRef.get(); @@ -242,17 +321,14 @@ export async function runThroneIngestion( const ltTs = now.toISOString(); if (opts?.fullSync) { - // Full sync: go back 1 year to capture all historical data const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); gtTs = oneYearAgo.toISOString(); logger.info(`Full sync requested, fetching from ${gtTs}`); } else if (syncDoc.exists) { const data = syncDoc.data() as SyncState; - // Use last ltTs as the new gtTs for incremental sync gtTs = data.lastLtTs; logger.info(`Incremental sync from ${gtTs}`); } else { - // First run: default to last 7 days const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); gtTs = sevenDaysAgo.toISOString(); logger.info(`Initial sync from ${gtTs}`); @@ -270,7 +346,6 @@ export async function runThroneIngestion( hasMore = data.hasMore; page++; - // Safety: cap at 100 pages if (page > 100) { logger.warn("Exceeded 100 pages, stopping pagination"); break; @@ -281,12 +356,12 @@ export async function runThroneIngestion( const {sessions, metrics} = normalizeSessions(allPages, studyId); logger.info(`Normalized: ${sessions.length} sessions, ${metrics.length} metrics`); - // Write to Firestore + // Write to user-scoped paths if (sessions.length > 0 || metrics.length > 0) { await writeToFirestore(db, sessions, metrics); } - // Update sync state + // Advance study-level sync cursor const syncState: SyncState = { lastRunAt: now.toISOString(), lastLtTs: ltTs, diff --git a/homeflow/hooks/use-surgery-date.ts b/homeflow/hooks/use-surgery-date.ts index 4ce894c..4677fb2 100644 --- a/homeflow/hooks/use-surgery-date.ts +++ b/homeflow/hooks/use-surgery-date.ts @@ -1,13 +1,19 @@ /** * Surgery Date Hook * - * Reads the scheduled surgery date from onboarding medical history data. - * Falls back to a placeholder date in dev builds when no real data exists. + * Resolves the surgery date with the following priority: + * 1. Firestore (authoritative — set after login via saveSurgeryDate) + * 2. OnboardingService / AsyncStorage (set during eligibility before login) + * 3. Dev placeholder (14 days from now, __DEV__ only) + * + * Re-fetches whenever the authenticated user changes. */ import { useState, useEffect } from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { STORAGE_KEYS } from '@/lib/constants'; +import { useAuth } from '@/lib/auth/auth-context'; +import { fetchSurgeryDate } from '@/src/services/throneFirestore'; +import { OnboardingService } from '@/lib/services/onboarding-service'; +import { DEV_FIREBASE_UID } from '@/lib/constants'; interface SurgeryDateInfo { /** The surgery date string (YYYY-MM-DD) or null */ @@ -20,7 +26,7 @@ interface SurgeryDateInfo { isPlaceholder: boolean; /** Loading state */ isLoading: boolean; - /** Study start date (YYYY-MM-DD) — at least 1 week before surgery */ + /** Study start date (YYYY-MM-DD) — 7 days before surgery */ studyStartDate: string | null; studyStartLabel: string; /** Study end date (YYYY-MM-DD) — 90 days after surgery */ @@ -28,6 +34,17 @@ interface SurgeryDateInfo { studyEndLabel: string; } +const NOT_SCHEDULED: Omit = { + date: null, + dateLabel: 'Not scheduled', + hasPassed: false, + isPlaceholder: true, + studyStartDate: null, + studyStartLabel: 'Not scheduled', + studyEndDate: null, + studyEndLabel: 'Not scheduled', +}; + // Dev placeholder: surgery 2 weeks from now function getPlaceholderDate(): string { const d = new Date(); @@ -42,107 +59,85 @@ function addDays(dateStr: string, days: number): string { } function formatDateLabel(dateStr: string): string { - const date = new Date(dateStr + 'T12:00:00'); - return date.toLocaleDateString('en-US', { + return new Date(dateStr + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', }); } +function buildInfo(dateStr: string, isPlaceholder: boolean): SurgeryDateInfo { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const surgDate = new Date(dateStr + 'T12:00:00'); + const startDate = addDays(dateStr, -7); + const endDate = addDays(dateStr, 90); + + return { + date: dateStr, + dateLabel: formatDateLabel(dateStr), + hasPassed: surgDate <= today, + isPlaceholder, + isLoading: false, + studyStartDate: startDate, + studyStartLabel: formatDateLabel(startDate), + studyEndDate: endDate, + studyEndLabel: formatDateLabel(endDate), + }; +} + export function useSurgeryDate(): SurgeryDateInfo { + const { user } = useAuth(); + const uid = user?.id ?? (__DEV__ ? DEV_FIREBASE_UID : null); + const [info, setInfo] = useState({ - date: null, - dateLabel: '', - hasPassed: false, - isPlaceholder: true, + ...NOT_SCHEDULED, isLoading: true, - studyStartDate: null, - studyStartLabel: '', - studyEndDate: null, - studyEndLabel: '', }); useEffect(() => { let cancelled = false; - async function loadSurgeryDate() { + async function load() { try { - // Check AsyncStorage directly for a surgery date field - const stored = await AsyncStorage.getItem(STORAGE_KEYS.MEDICAL_HISTORY); - let surgeryDate: string | null = null; - - if (stored) { - try { - const parsed = JSON.parse(stored); - surgeryDate = parsed.surgeryDate ?? null; - } catch { - // ignore parse errors + // 1. Firestore — most authoritative, available after login + if (uid) { + const firestoreDate = await fetchSurgeryDate(uid); + if (!cancelled && firestoreDate) { + setInfo(buildInfo(firestoreDate, false)); + return; } } if (cancelled) return; - const isPlaceholder = !surgeryDate; - const effectiveDate = surgeryDate ?? (__DEV__ ? getPlaceholderDate() : null); - - if (effectiveDate) { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const surgDate = new Date(effectiveDate + 'T12:00:00'); - const hasPassed = surgDate <= today; - - // Study start: 7 days before surgery; Study end: 90 days after surgery - const startDate = addDays(effectiveDate, -7); - const endDate = addDays(effectiveDate, 90); - - setInfo({ - date: effectiveDate, - dateLabel: formatDateLabel(effectiveDate), - hasPassed, - isPlaceholder, - isLoading: false, - studyStartDate: startDate, - studyStartLabel: formatDateLabel(startDate), - studyEndDate: endDate, - studyEndLabel: formatDateLabel(endDate), - }); + // 2. OnboardingService (AsyncStorage) — available before/after login + const onboardingData = await OnboardingService.getData(); + const localDate = onboardingData.eligibility?.surgeryDate ?? null; + + if (!cancelled && localDate) { + setInfo(buildInfo(localDate, false)); + return; + } + + if (cancelled) return; + + // 3. Dev placeholder + if (__DEV__) { + setInfo(buildInfo(getPlaceholderDate(), true)); } else { - setInfo({ - date: null, - dateLabel: 'Not scheduled', - hasPassed: false, - isPlaceholder: true, - isLoading: false, - studyStartDate: null, - studyStartLabel: 'Not scheduled', - studyEndDate: null, - studyEndLabel: 'Not scheduled', - }); + setInfo({ ...NOT_SCHEDULED, isLoading: false }); } } catch { if (!cancelled) { - setInfo({ - date: null, - dateLabel: 'Not scheduled', - hasPassed: false, - isPlaceholder: true, - isLoading: false, - studyStartDate: null, - studyStartLabel: 'Not scheduled', - studyEndDate: null, - studyEndLabel: 'Not scheduled', - }); + setInfo({ ...NOT_SCHEDULED, isLoading: false }); } } } - loadSurgeryDate(); - - return () => { - cancelled = true; - }; - }, []); + load(); + return () => { cancelled = true; }; + }, [uid]); return info; } diff --git a/homeflow/ios/Podfile.lock b/homeflow/ios/Podfile.lock index e4f138f..d967260 100644 --- a/homeflow/ios/Podfile.lock +++ b/homeflow/ios/Podfile.lock @@ -95,6 +95,8 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - ExpoPrint (15.0.8): + - ExpoModulesCore - ExpoSplashScreen (31.0.13): - ExpoModulesCore - ExpoSymbols (1.0.8): @@ -2178,6 +2180,7 @@ DEPENDENCIES: - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoLinking (from `../node_modules/expo-linking/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) + - ExpoPrint (from `../node_modules/expo-print/ios`) - ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`) - ExpoSymbols (from `../node_modules/expo-symbols/ios`) - ExpoSystemUI (from `../node_modules/expo-system-ui/ios`) @@ -2312,6 +2315,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-linking/ios" ExpoModulesCore: :path: "../node_modules/expo-modules-core" + ExpoPrint: + :path: "../node_modules/expo-print/ios" ExpoSplashScreen: :path: "../node_modules/expo-splash-screen/ios" ExpoSymbols: @@ -2494,6 +2499,7 @@ SPEC CHECKSUMS: ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37 ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583 + ExpoPrint: 813bfa965eda2ecc4b1cdc61612c820e239c382a ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d ExpoSymbols: 349ee2b4d7d5ff3ea8436467914f8a67635aa354 ExpoSystemUI: 2ad325f361a2fcd96a464e8574e19935c461c9cc diff --git a/homeflow/lib/constants.ts b/homeflow/lib/constants.ts index a5d437e..d99d7c7 100644 --- a/homeflow/lib/constants.ts +++ b/homeflow/lib/constants.ts @@ -85,7 +85,7 @@ export const CONSENT_VERSION = '1.0.0'; * Dev-only Firebase UID for testing Firestore queries without Apple Sign-In. * Used as uid fallback when DEV_BYPASS_AUTH is active and no user is signed in. */ -export const DEV_FIREBASE_UID = 'apple|002014.cccd386699574a369cfc75378d3770da.2143'; +export const DEV_FIREBASE_UID = 'CUziuLyPtDNO2IvSbsifXPKrNEk2'; /** * Study information diff --git a/homeflow/lib/services/throne-service.ts b/homeflow/lib/services/throne-service.ts index a1c5ba0..aca11e2 100644 --- a/homeflow/lib/services/throne-service.ts +++ b/homeflow/lib/services/throne-service.ts @@ -103,20 +103,26 @@ class StubThroneService implements IThroneService { } /** - * Request permission to access Throne data - * STUB: Always returns 'granted' after a delay to simulate API call + * Mark Throne as connected after the user has supplied their Throne User ID. + * + * SHORT-TERM: The caller (permissions screen) collects the Throne User ID via + * a text input and writes it to Firestore before calling this method. This + * method simply records the granted state locally. + * + * LONG-TERM (uncomment when real Throne SDK is available): + * Replace the body below with the OAuth flow. The SDK will return a throneUserId + * which the caller can then pass to saveThroneUserId() before calling this. + * + * // const throneResult = await ThroneSDK.authorize({ studyId: THRONE_STUDY_ID }); + * // await saveThroneUserId(firebaseUid, throneResult.userId); + * // this.permissionStatus = 'granted'; + * // await this.persistStatus(); + * // return this.permissionStatus; */ async requestPermission(): Promise { await this.initialize(); - - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // In stub mode, we'll simulate successful permission - // In production, this would open Throne's OAuth flow this.permissionStatus = 'granted'; await this.persistStatus(); - return this.permissionStatus; } diff --git a/homeflow/package-lock.json b/homeflow/package-lock.json index 70c46cb..0845135 100644 --- a/homeflow/package-lock.json +++ b/homeflow/package-lock.json @@ -3079,6 +3079,27 @@ "xml2js": "0.6.0" } }, + "node_modules/@expo/config-plugins/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@expo/config-plugins/node_modules/glob": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", @@ -3106,15 +3127,15 @@ } }, "node_modules/@expo/config-plugins/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3163,6 +3184,27 @@ "@babel/highlight": "^7.10.4" } }, + "node_modules/@expo/config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@expo/config/node_modules/glob": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", @@ -3190,15 +3232,15 @@ } }, "node_modules/@expo/config/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3345,16 +3387,37 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@expo/fingerprint/node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@expo/fingerprint/node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3370,12 +3433,12 @@ } }, "node_modules/@expo/fingerprint/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3558,16 +3621,37 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@expo/metro-config/node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/metro-config/node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@expo/metro-config/node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3583,12 +3667,12 @@ } }, "node_modules/@expo/metro-config/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -4539,27 +4623,6 @@ "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", "license": "MIT" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -6295,13 +6358,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6756,9 +6819,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -9254,9 +9317,9 @@ } }, "node_modules/expo-build-properties/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -9889,16 +9952,37 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/expo/node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/expo/node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/expo/node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9914,12 +9998,12 @@ } }, "node_modules/expo/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -13327,9 +13411,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -15992,9 +16076,9 @@ } }, "node_modules/tar": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", - "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/homeflow/src/services/cdaParser.ts b/homeflow/src/services/cdaParser.ts new file mode 100644 index 0000000..de9b236 --- /dev/null +++ b/homeflow/src/services/cdaParser.ts @@ -0,0 +1,197 @@ +/** + * CDA / HL7 Clinical Document Parser + * + * Decodes and parses base64-encoded clinical document attachments from Apple + * HealthKit FHIR DocumentReference resources. Apple Health delivers clinical + * notes as CDA (Clinical Document Architecture) XML — a structured HL7 format + * used by Epic, Cerner, and most major EHR systems. + * + * CDA XML structure: + * + * + * + * + *
+ * History of Present Illness + * Patient presents with… + *
+ *
+ *
+ *
+ *
+ * + * The block inside each
contains XHTML-like narrative markup + * (, , , ,
) that we strip to plain text. + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface CdaSection { + title: string; + text: string; +} + +export type CdaDocType = 'cda' | 'text' | 'pdf' | 'unknown'; + +export interface CdaParseResult { + /** What kind of document was detected in the attachment. */ + docType: CdaDocType; + /** Human-readable sections extracted from CDA structure. Empty for non-CDA. */ + sections: CdaSection[]; + /** Full plain-text summary (sections joined). Empty for binary/unknown. */ + plainText: string; + /** + * The decoded string content (XML for CDA, plain text for text/plain). + * Used for the Firebase Storage upload so we avoid the ArrayBuffer/Blob + * incompatibility in React Native when uploading raw base64. + */ + decodedContent: string | null; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Decode a base64 string to a UTF-8 string. + * Uses TextDecoder for proper multi-byte character support. + */ +function base64ToUtf8(b64: string): string { + // atob gives a binary string (one char per byte) + const binary = atob(b64.trim()); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + try { + return new TextDecoder('utf-8').decode(bytes); + } catch { + // TextDecoder not available — binary string is ASCII-safe for typical CDA + return binary; + } +} + +/** + * Returns true if the decoded string looks like a CDA/HL7 XML document. + */ +function isCda(decoded: string): boolean { + const head = decoded.slice(0, 2000); + return ( + head.includes('ClinicalDocument') || + head.includes('urn:hl7-org:v3') || + (head.trimStart().startsWith('/gi, '\n') + .replace(/<\/(?:paragraph|item|tr|th|td)>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /g, ' ') + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/[ \t]+/g, ' ') + .replace(/\n[ \t]+/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * Extract all
blocks from CDA XML. + * Each section has a and a <text> element containing the narrative. + */ +function parseCdaSections(xml: string): CdaSection[] { + const sections: CdaSection[] = []; + + // Match each <section>…</section> block (non-greedy, case-insensitive) + const sectionPattern = /<section(?:\s[^>]*)?>[\s\S]*?<\/section>/gi; + const sectionMatches = xml.match(sectionPattern) ?? []; + + for (const block of sectionMatches) { + // Extract <title> + const titleMatch = /<title(?:\s[^>]*)?>([^<]*)<\/title>/i.exec(block); + const title = titleMatch ? titleMatch[1].trim() : ''; + + // Extract the narrative <text> block (not <structuredBody> children) + // We want the first direct <text> inside this section, not nested ones + const textMatch = /<text(?:\s[^>]*)?>([\s\S]*?)<\/text>/i.exec(block); + const rawText = textMatch ? textMatch[1] : ''; + const text = stripTags(rawText); + + // Skip sections with no useful content + if (!text || text.length < 3) continue; + + sections.push({ title: title || 'Clinical Note', text }); + } + + return sections; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Parse a FHIR attachment from a HealthKit clinical note record. + * + * @param base64Data - The raw base64 string from attachment.data + * @param contentType - MIME type declared in the FHIR attachment + * @returns Structured parse result with human-readable sections and plain text + */ +export function parseClinicalAttachment( + base64Data: string, + contentType: string, +): CdaParseResult { + // Binary PDF — cannot decode to text on the client + if (contentType === 'application/pdf') { + return { + docType: 'pdf', + sections: [], + plainText: '', + decodedContent: null, + }; + } + + let decoded: string; + try { + decoded = base64ToUtf8(base64Data); + } catch { + return { docType: 'unknown', sections: [], plainText: '', decodedContent: null }; + } + + // Plain text + if (contentType === 'text/plain') { + return { + docType: 'text', + sections: [{ title: 'Clinical Note', text: decoded.trim() }], + plainText: decoded.trim(), + decodedContent: decoded, + }; + } + + // CDA XML (text/xml, application/xml, text/html, or sniffed from content) + if (isCda(decoded)) { + const sections = parseCdaSections(decoded); + const plainText = sections + .map(s => (s.title ? `${s.title}\n${s.text}` : s.text)) + .join('\n\n'); + + return { + docType: 'cda', + sections, + plainText, + decodedContent: decoded, + }; + } + + // Unknown — store raw decoded string for best-effort display + return { + docType: 'unknown', + sections: [], + plainText: decoded.slice(0, 500), + decodedContent: decoded, + }; +} diff --git a/homeflow/src/services/clinicalNotesSync.ts b/homeflow/src/services/clinicalNotesSync.ts index b2c3d57..6fc9141 100644 --- a/homeflow/src/services/clinicalNotesSync.ts +++ b/homeflow/src/services/clinicalNotesSync.ts @@ -13,7 +13,7 @@ * — raw document bytes (content-type preserved from FHIR attachment) * * Firestore: - * users/{uid}/clinicalNotes/{noteId} + * users/{uid}/clinical_notes/{noteId} * displayName, startDate, endDate, contentType, title?, * storageRef, fhirResourceType?, fhirSourceURL?, * medgemmaStatus, uploadedAt @@ -45,6 +45,8 @@ import { import { getClinicalNotes } from '@/lib/services/healthkit'; import type { ClinicalRecord } from '@/lib/services/healthkit'; import { db, getAuth } from './firestore'; +import { parseClinicalAttachment } from './cdaParser'; +import type { CdaSection } from './cdaParser'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -63,6 +65,19 @@ interface FhirAttachment { url?: string; // present when data is not embedded } +// ── Constants ───────────────────────────────────────────────────────────────── + +// Allowlist of content types accepted from FHIR attachment metadata. +// Any value outside this set is coerced to application/octet-stream. +const ALLOWED_CONTENT_TYPES = new Set([ + 'application/pdf', + 'application/xml', + 'application/xhtml+xml', + 'text/xml', + 'text/plain', + 'text/html', +]); + // ── Helpers ─────────────────────────────────────────────────────────────────── /** @@ -132,7 +147,7 @@ export async function syncClinicalNotes(): Promise<SyncClinicalNotesResult> { for (const note of notes) { // ── Idempotency check ──────────────────────────────────────────────── - const metaRef = doc(db, `users/${uid}/clinicalNotes/${note.id}`); + const metaRef = doc(db, `users/${uid}/clinical_notes/${note.id}`); const existing = await getDoc(metaRef); if (existing.exists()) { skipped++; @@ -141,49 +156,94 @@ export async function syncClinicalNotes(): Promise<SyncClinicalNotesResult> { // ── Extract attachment ─────────────────────────────────────────────── const attachment = extractAttachment(note.fhirResource); - const contentType = attachment?.contentType ?? 'application/pdf'; + const rawContentType = attachment?.contentType ?? 'application/xml'; + const contentType = ALLOWED_CONTENT_TYPES.has(rawContentType) + ? rawContentType + : 'application/octet-stream'; - // ── Upload to Firebase Storage (only when inline data is available) ── - let storagePath: string | null = null; + // ── Parse attachment (CDA XML → human-readable sections) ──────────── + // Most HealthKit clinical notes are CDA/HL7 XML, not readable PDFs. + // We decode and parse them here so the text is stored in Firestore + // for in-app display, while the raw decoded document goes to Storage + // for the downstream MedGemma research pipeline. + let parsedSections: CdaSection[] = []; + let parsedText: string = ''; + let parsedDocType: string = 'unknown'; if (attachment?.data) { - storagePath = `users/${uid}/clinical-notes/${note.id}`; + const parsed = parseClinicalAttachment(attachment.data, contentType); + parsedSections = parsed.sections; + parsedText = parsed.plainText; + parsedDocType = parsed.docType; + + // ── Upload to Firebase Storage ───────────────────────────────────── + // Upload the decoded string content (not raw base64) using 'raw' mode + // to avoid the React Native ArrayBuffer/Blob incompatibility that + // occurs with uploadString(…, 'base64'). + // Binary PDFs (docType === 'pdf') are skipped — they cannot be decoded + // to a string on the client and are uncommon from HealthKit. + const storagePath = `users/${uid}/clinical_notes/${note.id}`; const storageRef = ref(storage, storagePath); - await uploadString(storageRef, attachment.data, 'base64', { - contentType, - customMetadata: { - noteId: note.id, - displayName: note.displayName, - fhirSourceURL: note.fhirSourceURL ?? '', - }, - }); + if (parsed.decodedContent !== null) { + // Text or CDA XML — upload as decoded UTF-8 string + await uploadString(storageRef, parsed.decodedContent, 'raw', { + contentType: parsed.docType === 'cda' ? 'text/xml' : contentType, + customMetadata: { + noteId: note.id, + displayName: note.displayName, + docType: parsed.docType, + fhirSourceURL: note.fhirSourceURL ?? '', + }, + }); + } else { + // Binary PDF — upload raw base64 (best effort; may fail on some RN builds) + await uploadString(storageRef, attachment.data, 'base64', { + contentType, + customMetadata: { + noteId: note.id, + displayName: note.displayName, + docType: 'pdf', + fhirSourceURL: note.fhirSourceURL ?? '', + }, + }); + } console.log( - `[ClinicalNotes] Uploaded ${storagePath} (${contentType}, ~${attachment.size ?? '?'} bytes)`, + `[ClinicalNotes] Uploaded ${storagePath} (docType=${parsed.docType}, sections=${parsedSections.length})`, ); uploaded++; } else { - console.log( - `[ClinicalNotes] Note ${note.id} ("${note.displayName}") has no inline data — recording metadata only`, - ); + if (__DEV__) { + console.log( + `[ClinicalNotes] Note ${note.id} ("${note.displayName}") has no inline data — metadata only`, + ); + } } - // ── Write metadata to Firestore (always, for every note) ──────────── - // storageRef is null when the attachment was url-only; the MedGemma - // batch job should skip docs where storageRef === null. + // ── Write metadata + parsed text to Firestore ──────────────────────── + // parsedText and parsedSections make the content readable in-app without + // needing to download from Storage. storageRef is set when a file was + // uploaded (null for url-only attachments). + const storagePath = attachment?.data + ? `users/${uid}/clinical_notes/${note.id}` + : null; + await setDoc(metaRef, { displayName: note.displayName, startDate: Timestamp.fromDate(new Date(note.startDate)), endDate: Timestamp.fromDate(new Date(note.endDate)), contentType, title: attachment?.title ?? null, - storageRef: storagePath, // null → no inline data available + storageRef: storagePath, attachmentUrl: attachment?.url ?? null, fhirResourceType: note.fhirResourceType ?? null, fhirSourceURL: note.fhirSourceURL ?? null, - // End-of-study MedGemma pipeline reads all docs where this == 'pending' - // and storageRef != null (has uploadable content) + // Parsed readable content — populated for CDA XML and plain text + parsedDocType, + parsedText: parsedText || null, + parsedSections: parsedSections.length > 0 ? parsedSections : null, + // MedGemma pipeline: reads docs where status == 'pending' and storageRef != null medgemmaStatus: storagePath ? 'pending' : 'no-data', uploadedAt: serverTimestamp(), }); diff --git a/homeflow/src/services/fhirPrefillSync.ts b/homeflow/src/services/fhirPrefillSync.ts index 342179f..3cd8652 100644 --- a/homeflow/src/services/fhirPrefillSync.ts +++ b/homeflow/src/services/fhirPrefillSync.ts @@ -8,11 +8,15 @@ * * Firestore path * ────────────── - * users/{uid}/medicalHistoryPrefill/latest - * — full MedicalHistoryPrefill JSON + * users/{uid}/medical_history_prefill/latest + * — full MedicalHistoryPrefill JSON (machine-parsed, unconfirmed) * — generatedAt (server timestamp) * — sourceRecordCounts (how many records fed the parser) * + * This document is the raw prefill. The user-confirmed, combined document + * lives at users/{uid}/medical_history/current and is written when the + * user completes the Medical History onboarding screen. + * * This document is overwritten on every sync; it always reflects the * most recent clinical records available on the device. */ @@ -114,14 +118,14 @@ export async function syncFhirPrefill(): Promise<SyncFhirPrefillResult> { const prefill = buildMedicalHistoryPrefill(clinicalInput, hkDemographics); // Write to Firestore — overwrite on each sync so it's always current - const ref = doc(db, `users/${uid}/medicalHistoryPrefill/latest`); + const ref = doc(db, `users/${uid}/medical_history_prefill/latest`); await setDoc(ref, { ...prefill, generatedAt: serverTimestamp(), sourceRecordCounts, }); - console.log('[FhirPrefill] Written to Firestore → users/' + uid + '/medicalHistoryPrefill/latest'); + console.log('[FhirPrefill] Written to Firestore → users/' + uid + '/medical_history_prefill/latest'); return { ok: true, prefill, sourceRecordCounts }; } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/homeflow/src/services/healthkitSync.ts b/homeflow/src/services/healthkitSync.ts index 347240c..6076a27 100644 --- a/homeflow/src/services/healthkitSync.ts +++ b/homeflow/src/services/healthkitSync.ts @@ -484,30 +484,34 @@ export async function bootstrapHealthKitSync(): Promise<void> { syncFhirPrefill(), ]); - if (hkResult.ok) { - console.log("[HealthKit] bootstrapHealthKitSync: quantity metrics synced OK", hkResult.results); - } else { - console.warn("[HealthKit] bootstrapHealthKitSync: quantity metrics had errors", hkResult.results); - } + if (__DEV__) { + // Log detailed sync results only in development — these objects contain + // health metric categories and clinical data counts (PHI-adjacent). + if (hkResult.ok) { + console.log("[HealthKit] bootstrapHealthKitSync: quantity metrics synced OK", hkResult.results); + } else { + console.warn("[HealthKit] bootstrapHealthKitSync: quantity metrics had errors", hkResult.results); + } - if (sleepResult.ok) { - console.log(`[HealthKit] bootstrapHealthKitSync: sleep synced OK — written: ${sleepResult.written}`); - } else { - console.warn("[HealthKit] bootstrapHealthKitSync: sleep sync error:", sleepResult.error); - } + if (sleepResult.ok) { + console.log(`[HealthKit] bootstrapHealthKitSync: sleep synced OK — written: ${sleepResult.written}`); + } else { + console.warn("[HealthKit] bootstrapHealthKitSync: sleep sync error:", sleepResult.error); + } - if (clinicalResult.ok) { - console.log( - `[HealthKit] bootstrapHealthKitSync: clinical notes synced OK — uploaded: ${clinicalResult.uploaded}, skipped: ${clinicalResult.skipped}`, - ); - } else { - console.warn("[HealthKit] bootstrapHealthKitSync: clinical notes sync error:", clinicalResult.error); - } + if (clinicalResult.ok) { + console.log( + `[HealthKit] bootstrapHealthKitSync: clinical notes synced OK — uploaded: ${clinicalResult.uploaded}, skipped: ${clinicalResult.skipped}`, + ); + } else { + console.warn("[HealthKit] bootstrapHealthKitSync: clinical notes sync error:", clinicalResult.error); + } - if (fhirResult.ok) { - console.log("[HealthKit] bootstrapHealthKitSync: FHIR prefill synced OK", fhirResult.sourceRecordCounts); - } else { - console.warn("[HealthKit] bootstrapHealthKitSync: FHIR prefill sync error:", fhirResult.error); + if (fhirResult.ok) { + console.log("[HealthKit] bootstrapHealthKitSync: FHIR prefill synced OK", fhirResult.sourceRecordCounts); + } else { + console.warn("[HealthKit] bootstrapHealthKitSync: FHIR prefill sync error:", fhirResult.error); + } } } catch (err) { console.error("[HealthKit] bootstrapHealthKitSync: unexpected error:", err); diff --git a/homeflow/storage.rules b/homeflow/storage.rules index 56a956b..85ada24 100644 --- a/homeflow/storage.rules +++ b/homeflow/storage.rules @@ -8,9 +8,26 @@ service firebase.storage { return request.auth != null && request.auth.uid == uid; } - // All user data is scoped under users/{uid}/ + // Clinical notes uploaded from HealthKit — 20 MB cap per file. + match /users/{uid}/clinical_notes/{noteId} { + allow read: if isOwner(uid); + allow write: if isOwner(uid) + && request.resource.size < 20 * 1024 * 1024; + } + + // Consent PDFs — 5 MB cap, write-once (no overwriting a submitted consent). + match /users/{uid}/consent_pdfs/{fileName} { + allow read: if isOwner(uid); + allow write: if isOwner(uid) + && resource == null + && request.resource.size < 5 * 1024 * 1024; + } + + // Catch-all for any other paths under users/{uid}/ — 10 MB cap. match /users/{uid}/{allPaths=**} { - allow read, write: if isOwner(uid); + allow read: if isOwner(uid); + allow write: if isOwner(uid) + && request.resource.size < 10 * 1024 * 1024; } } }