Patient Graph Data Model
The Patient Graph is Loop Health’s unified clinical data system, consolidating all patient health data into a single PostgreSQL schema with comprehensive audit trails and RBAC security.
Schema Location: packages/shared/src/db/patient-graph-schema.ts
Database: health Supabase project
ORM: Drizzle ORM with full TypeScript types
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ PATIENT GRAPH SCHEMA │
│ (patient_graph.*) │
├─────────────────────────────────────────────────────────────┤
│ │
│ Core Identity │
│ ├─ customer_profiles (demographics, tier, conditions) │
│ └─ identity_mappings (Clerk, Rimo, BigCommerce) │
│ │
│ Clinical Data │
│ ├─ lab_results (parsed biomarkers) │
│ ├─ protocols (treatment protocols) │
│ ├─ patient_events (clinical timeline) │
│ └─ conversation_history (Luna AI logs) │
│ │
│ Treatment Lifecycle (Rimo Integration) │
│ ├─ patient_treatments (treatment orders) │
│ └─ patient_prescriptions (prescription fulfillment) │
│ │
│ Subscriptions & Billing │
│ └─ subscriptions (recurring products, auto-refill) │
│ │
│ Wearables & Biometrics │
│ ├─ patient_wearable_readings (raw sensor data) │
│ ├─ patient_wearable_daily_stats (aggregated stats) │
│ └─ wearable_data (DEPRECATED - use readings/stats) │
│ │
│ Security & Compliance │
│ └─ rbac_logs (HIPAA audit trail) │
│ │
└─────────────────────────────────────────────────────────────┘Entity Relationships
Complete Enum Definitions
biological_sex
Biological sex for sex-specific biomarker reference ranges.
'male' | 'female' | 'other'Usage: Customer profiles, biomarker range lookups
subscription_status
Product subscription lifecycle states.
'pending' // Initial state, awaiting approval
'approved' // Clinician approved (Loop Med only)
'rejected' // Clinician rejected (Loop Med only)
'active' // Subscription is active and billing
'paused' // Temporarily paused by customer
'cancelled' // Permanently cancelled
'completed' // Naturally completed (finite protocols)New in v1.2.0: Added approved, rejected, completed for Loop Med treatment workflows.
State Machine:
pending → approved → active → [paused ⇄ active] → completed/cancelled
pending → rejected (terminal)subscription_interval
Recurring subscription intervals.
'weekly' | 'monthly' | 'quarterly' | 'biannual' | 'annual'Default: monthly
protocol_status
Treatment protocol lifecycle states.
'active' // Currently running
'paused' // Temporarily on hold
'completed' // Successfully finished
'cancelled' // Terminated earlylab_status
Lab result processing states.
'pending' // Uploaded, not yet reviewed
'reviewed' // Clinician reviewed
'archived' // Archived for compliancepatient_event_type
Clinical timeline event types.
'lab_upload' // Lab report uploaded
'lab_parsed' // Biomarkers extracted
'protocol_created' // Protocol created
'protocol_started' // Protocol started
'protocol_paused' // Protocol paused
'protocol_completed' // Protocol completed
'protocol_cancelled' // Protocol cancelled
'protocol_check_in' // Patient check-in completed
'note_added' // Clinical note added
'profile_updated' // Profile updated
'consent_granted' // Consent given
'consent_revoked' // Consent revokedNew in v1.2.0: Added protocol_check_in for patient self-reporting.
rbac_role
Role-based access control roles.
'admin' // Full access, all resources
'staff' // Internal staff, most resources
'support' // Customer support, read-only patient data
'provider' // Clinicians, read patient data + write clinical notes
'customer' // Patients, own data only (ownOnly=true)Permissions Matrix: See RBAC Documentation
rbac_action
RBAC action verbs.
'read' // View data
'write' // Create or update
'delete' // Soft or hard delete
'export' // Export data (compliance-restricted)rbac_resource
Protected resources under RBAC.
'profile' // Customer profiles
'lab_results' // Lab reports and biomarkers
'protocols' // Treatment protocols
'events' // Patient events
'audit_log' // RBAC logs (admin only)
'conversation_history' // AI conversation logs
'treatment' // Rimo treatments (new in v1.2.0)New in v1.2.0: Added treatment resource for Loop Med treatment management.
rbac_outcome
RBAC access decision results.
'allowed' | 'denied'prescription_status
Prescription fulfillment lifecycle (Rimo integration).
'pending' // Created, awaiting transmission
'processing' // Transmission in progress
'transmitted' // Sent to pharmacy
'failed' // Transmission failed
'shipped' // Pharmacy shipped
'delivered' // Confirmed delivery
'cancelled' // Order cancelledwearable_source
Wearable device providers (for legacy wearable_data table).
'oura' | 'whoop' | 'garmin' | 'fitbit' | 'apple_health' | 'dexcom' | 'libre' | 'manual'⚠️ Deprecated: Use patient_wearable_readings with source field instead.
wearable_metric_type
Wearable metric categories (for legacy wearable_data table).
'sleep' | 'recovery' | 'hrv' | 'glucose' | 'strain' | 'activity' | 'readiness' | 'body'⚠️ Deprecated: Use patient_wearable_readings with dataType field instead.
wearable_data_type
Individual wearable metrics (new granular system).
// Sleep metrics
'sleep_duration' | 'sleep_score' | 'rem_sleep' | 'deep_sleep' | 'light_sleep' | 'awakenings'
// Recovery & HRV
'hrv' | 'resting_heart_rate' | 'recovery_score'
// Glucose
'glucose' | 'glucose_variability'
// Activity
'steps' | 'calories' | 'active_minutes' | 'strain_score'
// Body composition
'weight' | 'body_fat' | 'muscle_mass'identifier_type
Identity mapping types for cross-system resolution.
'clerk_user_id' // Clerk authentication ID
'email' // Email address
'bigcommerce_customer_id' // BigCommerce CRM
'stripe_customer_id' // Stripe billing
'hubspot_contact_id' // HubSpot CRM (legacy)
'phone' // Phone number
'fingerprint_visitor_id' // Fingerprint.jsTable Reference
customer_profiles
Patient demographic and clinical profiles.
| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK, default random | Profile UUID |
externalId | text | unique, not null | Clerk user ID |
email | text | unique, not null | Email address |
firstName | text | First name | |
lastName | text | Last name | |
dateOfBirth | date | Birth date (for age calculations) | |
biologicalSex | biological_sex | Biological sex (for reference ranges) | |
phoneNumber | text | Phone number | |
timezone | text | default ‘America/New_York’ | IANA timezone |
subscriptionTier | text | default ‘free’ | Subscription tier |
conditions | text[] | default [] | Medical conditions |
medications | text[] | default [] | Current medications |
allergies | text[] | default [] | Known allergies |
tags | jsonb | default [] | User tags for segmentation |
metadata | jsonb | default | Extensible metadata |
createdAt | timestamp | not null, default now() | Creation timestamp |
updatedAt | timestamp | not null, default now() | Last update timestamp |
Indexes:
customer_profiles_external_id_idxonexternalIdcustomer_profiles_email_idxonemailcustomer_profiles_tier_idxonsubscriptionTier
Example:
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"externalId": "user_2a1b3c4d5e6f",
"email": "patient@example.com",
"firstName": "Jane",
"lastName": "Doe",
"dateOfBirth": "1985-06-15",
"biologicalSex": "female",
"phoneNumber": "+12125551234",
"timezone": "America/Los_Angeles",
"subscriptionTier": "premium",
"conditions": ["hypothyroidism", "pcos"],
"medications": ["levothyroxine 100mcg"],
"allergies": ["penicillin"],
"tags": ["high-risk", "monthly-check-in"],
"metadata": {
"source": "referral",
"referrer_id": "provider_123"
}
}subscriptions
Product subscriptions for auto-refill and recurring orders.
Alias: patientTreatments (for treatment workflows)
| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK | Subscription ID |
customerId | uuid | FK → customer_profiles, not null | Patient |
productId | text | not null | Product SKU |
productName | text | not null | Product name |
offeringId | text | Rimo offering ID (Loop Med) | |
offeringName | text | Rimo offering name | |
clinicianId | text | Assigned clinician ID | |
clinicianName | text | Clinician name | |
monthlyPriceCents | number | stored as text | Monthly price (cents) |
notes | text | Clinical notes | |
bigcommerceProductId | text | BigCommerce product ID | |
rechargeSubscriptionId | text | Recharge subscription ID | |
rimoTreatmentId | text | Rimo treatment ID | |
status | subscription_status | not null, default ‘pending’ | Current status |
approvedAt | timestamp | Clinician approval time | |
startedAt | timestamp | Subscription start time | |
completedAt | timestamp | Completion time | |
cancelledAt | timestamp | Cancellation time | |
pausedAt | timestamp | Last pause time | |
interval | subscription_interval | not null, default ‘monthly’ | Billing interval |
intervalCount | text | not null, default ‘30’ | Days per interval |
discountPercentage | text | not null, default ‘10’ | Discount % |
nextOrderDate | timestamp | Next scheduled order | |
lastOrderDate | timestamp | Last order date | |
convertedFromReorders | text | not null, default ‘0’ | Migration flag |
metadata | jsonb | not null, default | Additional data |
createdAt | timestamp | not null, default now() | |
updatedAt | timestamp | not null, default now() |
New in v1.2.0: Added treatment management fields (offeringId, clinicianId, approvedAt, startedAt, completedAt, cancelledAt, pausedAt, monthlyPriceCents, notes) for Loop Med telehealth workflows.
Indexes:
subscriptions_customer_id_idxoncustomerIdsubscriptions_status_idxonstatussubscriptions_product_id_idxonproductIdsubscriptions_recharge_subscription_id_idxonrechargeSubscriptionId
Example (Loop Med Treatment):
{
"id": "sub_123",
"customerId": "cust_456",
"productId": "semaglutide-0.25mg",
"productName": "Semaglutide 0.25mg",
"offeringId": "rimo_offering_789",
"offeringName": "GLP-1 Weight Management",
"clinicianId": "dr_smith_123",
"clinicianName": "Dr. Sarah Smith, MD",
"monthlyPriceCents": 29900,
"notes": "Starting dose, titrate after 4 weeks",
"status": "approved",
"approvedAt": "2026-03-20T10:00:00Z",
"startedAt": "2026-03-21T00:00:00Z",
"interval": "monthly",
"nextOrderDate": "2026-04-21T00:00:00Z"
}patient_prescriptions
Prescription orders fulfilled through Rimo Health pharmacy.
| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK | Prescription ID |
customerId | uuid | FK → customer_profiles, not null | Patient |
treatmentId | uuid | FK → subscriptions, not null | Associated treatment |
rimoOrderId | text | unique, not null | Rimo order ID |
medicationName | text | not null | Medication name |
dosage | text | not null | Dosage (e.g., “0.25mg”) |
quantity | number | stored as jsonb | Quantity dispensed |
refills | number | stored as jsonb | Refills authorized |
prescribedDate | timestamp | Prescription written date | |
nextRefillDate | timestamp | Next refill due date | |
status | prescription_status | not null, default ‘pending’ | Fulfillment status |
pharmacyId | text | Fulfilling pharmacy ID | |
pharmacyName | text | Pharmacy name | |
trackingNumber | text | Shipment tracking | |
transmittedAt | timestamp | Sent to pharmacy at | |
shippedAt | timestamp | Shipped at | |
deliveredAt | timestamp | Delivered at | |
cancelledAt | timestamp | Cancelled at | |
metadata | jsonb | not null, default | Additional data |
createdAt | timestamp | not null, default now() | |
updatedAt | timestamp | not null, default now() |
New in v1.2.0: Added prescribedDate and nextRefillDate for prescription tracking and refill automation.
Indexes:
patient_prescriptions_customer_id_idxoncustomerIdpatient_prescriptions_treatment_id_idxontreatmentIdpatient_prescriptions_rimo_order_id_idxonrimoOrderIdpatient_prescriptions_status_idxonstatus
patient_wearable_readings
Raw wearable device sensor readings (granular, time-series data).
| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK | Reading ID |
customerId | uuid | FK → customer_profiles, not null | Patient |
dataType | wearable_data_type | not null | Metric type |
value | numeric | not null | Measured value |
unit | text | not null | Unit of measurement |
deviceId | text | Device identifier | |
deviceType | text | Device type (oura, whoop, etc.) | |
source | text | not null | Data source |
recordedAt | timestamp | not null | Measurement time |
metadata | jsonb | not null, default | Additional context |
createdAt | timestamp | not null, default now() |
Indexes:
patient_wearable_readings_customer_id_idxoncustomerIdpatient_wearable_readings_data_type_idxondataTypepatient_wearable_readings_recorded_at_idxonrecordedAt
Example:
{
"id": "reading_123",
"customerId": "cust_456",
"dataType": "glucose",
"value": 95.5,
"unit": "mg/dL",
"deviceId": "dexcom_789",
"deviceType": "dexcom",
"source": "dexcom_api",
"recordedAt": "2026-03-20T14:30:00Z",
"metadata": {
"trend": "stable",
"alert_triggered": false
}
}patient_wearable_daily_stats
Aggregated daily statistics from wearable devices (cached for performance).
| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK | Stat ID |
customerId | uuid | FK → customer_profiles, not null | Patient |
date | date | not null | Date (YYYY-MM-DD) |
dataType | wearable_data_type | not null | Metric type |
avgValue | numeric | Daily average | |
minValue | numeric | Daily minimum | |
maxValue | numeric | Daily maximum | |
sumValue | numeric | Daily sum (for cumulative metrics) | |
count | integer | Number of readings | |
unit | text | not null | Unit of measurement |
metadata | jsonb | not null, default | Additional aggregations |
createdAt | timestamp | not null, default now() | |
updatedAt | timestamp | not null, default now() |
Unique Constraint: (customerId, date, dataType) - one stat per customer/date/metric
Indexes:
patient_wearable_daily_stats_customer_id_idxoncustomerIdpatient_wearable_daily_stats_date_idxondatepatient_wearable_daily_stats_data_type_idxondataType
Caching Strategy:
- 5-minute TTL in memory cache
- Background aggregation via Trigger.dev
- Upsert on sync to prevent duplicates
Migration History
| Version | Date | Changes |
|---|---|---|
| v1.0.0 | 2026-02-21 | Initial schema |
| v1.1.0 | 2026-03-10 | Add wearable readings/daily stats tables |
| v1.2.0 | 2026-03-20 | Add treatment management fields (Loop Med) |
v1.2.0 Changes (March 20, 2026)
Subscriptions table:
- Added
offeringId,offeringName(Rimo offering tracking) - Added
clinicianId,clinicianName(provider assignment) - Added
monthlyPriceCents(pricing) - Added
notes(clinical notes) - Added
approvedAt,startedAt,completedAt,cancelledAt,pausedAt(lifecycle timestamps) - Added
approved,rejected,completedtosubscription_statusenum
Prescriptions table:
- Added
prescribedDate(prescription written date) - Added
nextRefillDate(refill automation)
Events table:
- Added
protocol_check_intopatient_event_typeenum
RBAC:
- Added
treatmenttorbac_resourceenum
Common Query Patterns
Get patient with full clinical context
import { createRepositories } from '@loop/patient-graph';
import { db } from './db';
const repos = createRepositories(db);
const profile = await repos.profiles.findByExternalId('clerk_user_123');
const labs = await repos.labs.list({ customerId: profile.data.id, limit: 10 });
const protocols = await repos.protocols.list({ customerId: profile.data.id });
const events = await repos.events.list({ customerId: profile.data.id, limit: 50 });Aggregate wearable data
const glucoseStats = await repos.wearableData.getDailyStatsForDateRange(
customerId,
'glucose',
'2026-03-01',
'2026-03-20'
);
// Returns array of daily aggregations with avg/min/maxTreatment workflow (Loop Med)
// 1. Create treatment (pending approval)
const treatment = await repos.treatments.create({
customerId: 'cust_123',
rimoTreatmentId: 'rimo_456',
offeringId: 'offering_789',
offeringName: 'GLP-1 Weight Management',
status: 'pending',
});
// 2. Clinician approves
await repos.treatments.update(treatment.data.id, {
status: 'approved',
clinicianId: 'dr_smith',
clinicianName: 'Dr. Sarah Smith, MD',
approvedAt: new Date().toISOString(),
notes: 'Starting dose, titrate after 4 weeks',
});
// 3. Create prescription
const rx = await repos.prescriptions.create({
customerId: 'cust_123',
treatmentId: treatment.data.id,
rimoOrderId: 'rimo_order_999',
medicationName: 'Semaglutide',
dosage: '0.25mg',
quantity: 4,
refills: 3,
prescribedDate: new Date().toISOString(),
nextRefillDate: addMonths(new Date(), 1).toISOString(),
});Best Practices
Always use repositories, never raw SQL
✅ Do:
const repos = createRepositories(db);
const result = await repos.profiles.findById(id);❌ Don’t:
const result = await db.query('SELECT * FROM customer_profiles WHERE id = $1', [id]);Handle Result<T> properly
All repository methods return Result<T> with ok and error:
const result = await repos.profiles.findById(id);
if (!result.ok) {
console.error('Failed to fetch profile:', result.error);
return;
}
const profile = result.data; // Type-safe accessUse transactions for multi-step operations
await db.transaction(async (tx) => {
const repos = createRepositories(tx);
const treatment = await repos.treatments.create({...});
const event = await repos.events.create({
type: 'treatment_approved',
customerId: treatment.data.customerId,
});
});Always log RBAC access
await repos.rbacLogs.create({
actorId: user.id,
actorRole: 'provider',
customerId: patientId,
resource: 'lab_results',
action: 'read',
outcome: 'allowed',
});Schema Visualization Tools
View Live Schema:
cd packages/shared
pnpm drizzle-kit studio
# Opens Drizzle Studio at http://localhost:4983Generate ER Diagram:
cd packages/shared
pnpm drizzle-kit introspect --out=./docs/schema.pngRelated Documentation
- API Reference — HTTP endpoints and curl examples
- Repositories — Repository method reference
- RBAC — Role-based access control
- Audit Logging — Compliance logging
- Identity Resolution — Cross-system mapping