Database Schema
PostgreSQL database on Neon (serverless).
Current version: V3 (JSONB-first design)
Core Tables
patients
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinic_id UUID NOT NULL REFERENCES clinics(id),
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
date_of_birth DATE NOT NULL,
phi_data JSONB DEFAULT '{}'::jsonb, -- encrypted contact info
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
phi_data structure:
{
"contact": {
"email": "patient@example.com",
"phone": "555-0123"
},
"mrn": "MR123456"
}
form_templates
CREATE TABLE form_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinic_id UUID REFERENCES clinics(id),
name VARCHAR(255) NOT NULL,
description TEXT,
definition JSONB NOT NULL, -- form structure
is_active BOOLEAN DEFAULT TRUE,
version VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW()
);
definition structure:
{
"title": "TMS Intake",
"sections": [
{
"title": "Demographics",
"questions": [...]
}
]
}
form_assignments
CREATE TABLE form_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinic_id UUID NOT NULL,
patient_id UUID NOT NULL REFERENCES patients(id),
form_template_id UUID NOT NULL REFERENCES form_templates(id),
assigned_by_user_id UUID REFERENCES users(id),
assigned_to_user_id UUID REFERENCES users(id),
status VARCHAR(50) DEFAULT 'pending',
portal_token VARCHAR(255) UNIQUE, -- for patient link
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
form_submissions
CREATE TABLE form_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinic_id UUID NOT NULL,
assignment_id UUID NOT NULL UNIQUE REFERENCES form_assignments(id),
patient_id UUID NOT NULL REFERENCES patients(id),
submitted_by_user_id UUID REFERENCES users(id),
form_type VARCHAR(100) NOT NULL,
form_version VARCHAR(50) NOT NULL,
form_data JSONB NOT NULL, -- patient answers
processing_results JSONB DEFAULT '{}'::jsonb,
-- Stores AI summaries:
-- {
-- "summaries": {
-- "clinician": "...",
-- "patient": "...",
-- "pa": "..."
-- }
-- }
submission_status VARCHAR(50) DEFAULT 'submitted',
processing_status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinic_id UUID NOT NULL REFERENCES clinics(id),
email VARCHAR(255) UNIQUE NOT NULL,
full_name VARCHAR(255),
role VARCHAR(50) NOT NULL, -- admin, staff, referrer
is_active BOOLEAN DEFAULT TRUE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
audit_logs
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type VARCHAR(100) NOT NULL,
user_id UUID REFERENCES users(id),
clinic_id UUID,
resource_type VARCHAR(100),
resource_id UUID,
event_data JSONB DEFAULT '{}'::jsonb,
-- Stores:
-- {
-- "action": "READ_PATIENT",
-- "ip_address": "192.168.1.1",
-- "user_agent": "Mozilla/5.0...",
-- "metadata": {...}
-- }
created_at TIMESTAMPTZ DEFAULT NOW()
);
clinics
CREATE TABLE clinics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Row-Level Security (RLS)
All tables have RLS policies enabled.
Users only see data from their clinic.
How RLS Works
- User logs in with JWT
- Backend extracts user_id, clinic_id, role from JWT
- Backend sets PostgreSQL session context:
SELECT set_config('app.user_id', 'user-uuid', true);
SELECT set_config('app.clinic_id', 'clinic-uuid', true);
SELECT set_config('app.role', 'staff', true);
- All queries automatically filtered by RLS policies
Example RLS Policy
CREATE POLICY patients_clinic_isolation ON patients
FOR ALL
USING (clinic_id = current_setting('app.clinic_id')::uuid);
Superadmins bypass RLS to see all clinics.
Indexes
Key indexes for performance:
CREATE INDEX idx_patients_clinic ON patients(clinic_id);
CREATE INDEX idx_patients_name ON patients(first_name, last_name);
CREATE INDEX idx_patients_dob ON patients(date_of_birth);
CREATE INDEX idx_submissions_patient ON form_submissions(patient_id);
CREATE INDEX idx_submissions_clinic ON form_submissions(clinic_id);
CREATE INDEX idx_submissions_status ON form_submissions(submission_status);
CREATE INDEX idx_audit_logs_user ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_created ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_event ON audit_logs(event_type);
Migrations
Migrations stored in /migrations directory.
Current version: 6d4e4696f15a (V3 consolidated schema)
Run migrations:
# Using Neon SQL Editor
# Or via migration scripts
JSONB Fields
V3 uses JSONB for flexible data:
Benefits:
- No schema migrations for form changes
- Store variable form data
- Efficient indexing with GIN indexes
- Native JSON operators
Querying JSONB:
-- Extract field
SELECT phi_data->>'email' FROM patients;
-- Filter by nested field
WHERE form_data->>'age' = '30';
-- Check if field exists
WHERE processing_results ? 'summaries';
Encryption
PHI fields (phi_data, form_data) are encrypted at rest by PostgreSQL.
Additional field-level encryption can be added for extra security.
Backup and Recovery
Neon provides:
- Continuous backups
- Point-in-time recovery
- Encrypted snapshots
- 7-day retention (configurable)
Connection Details
Database connects via WebSockets (Neon serverless).
Connection string in environment: DATABASE_URL
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL);
No connection pooling needed (serverless auto-scales).