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

  1. User logs in with JWT
  2. Backend extracts user_id, clinic_id, role from JWT
  3. 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);
  1. 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).