Skip to content

Patient Onboarding Flow

Overview

Patient onboarding is the process of adding a new patient to the system with a complete user account, portable patient profile, and organization link — all in one atomic transaction.

Key concepts

users              → auth identity (login, email) — managed by Clerk
patient_persons    → real-world person + portable profile (name, DOB, phone, etc.)
patients           → org-patient link (one per org per person)

Onboarding creates all three. A patient can exist at multiple orgs — they'll have one patient_persons record and one patients record per org.


Onboarding vs Regular Patient Creation

Regular Patient Creation (POST /v1/patients)

Creates only an org-patient link:

  • Requires an existing patient_person_id
  • Just links an existing person to the current organization as a patient
  • Manual, simple — useful when the person already has a profile

Patient Onboarding (POST /v1/patients/onboard)

Creates everything needed for a new patient:

  • Creates or finds a Clerk user account
  • Creates internal users record
  • Creates patient_persons record with name and phone
  • Creates patients org-patient link
  • Adds user to organization
  • All in one atomic transaction

Use onboarding when: Adding a completely new patient who doesn't have an account or profile yet.

Use regular creation when: Adding an existing person (already has a patient_person_id) to a new organization.


Onboarding Flow Details

Step-by-Step Process

1. Validate Input
   ├─ name (required)
   ├─ email (required, valid format)
   ├─ phone (required)
   └─ organization_id (required)

2. Check if User Exists (by email)

   ├─ User EXISTS
   │  ├─ Find their patient_persons record
   │  ├─ Check if already a patient in this org
   │  │  ├─ Already a patient → Return 409 conflict
   │  │  └─ Not a patient → Create patients link record
   │  └─ Continue to step 6

   └─ User DOESN'T EXIST
      └─ Continue to step 3

3. Create User Account (if doesn't exist)
   ├─ Create user in Clerk
   │  └─ Clerk handles password setup (sends email)
   ├─ Store Clerk user ID
   └─ Create internal users record
      ├─ email
      ├─ username (defaults to email)
      ├─ role = 'patient'
      └─ clerk_user_id (link to Clerk)

4. Create Patient Person (portable profile)
   ├─ user_id (from step 3 or existing)
   ├─ name
   └─ phone_encrypted (AES-256-GCM, stored on patient_persons)

5. Create Patients Link (org-patient record)
   ├─ organization_id
   └─ patient_person_id (from step 4)

6. Add User to Organization
   └─ Insert into user_organizations (if not already present)

7. Emit Webhook Event
   └─ Event: patient.onboarded
      ├─ patient_id          (patients.id — the org-patient link)
      ├─ patient_person_id   (patient_persons.id — the portable profile)
      ├─ user_id
      ├─ organization_id
      └─ user_created (boolean)

8. Return Response
   └─ patient object + metadata

Transaction Boundaries

The entire onboarding flow runs in a single database transaction:

go
tx := db.Begin()
defer tx.Rollback()

// 1. Check/create user
// 2. Create patient_persons (or find existing)
// 3. Create patients link
// 4. Add to organization
// 5. Audit log

tx.Commit()

If any step fails, the entire operation is rolled back — no partial data is created.


API Request/Response

Request

bash
POST /v1/patients/onboard
Content-Type: application/json
Authorization: Bearer <admin_token>

{
  "name": "John Doe",
  "email": "[email protected]",
  "phone": "+31612345678",
  "organization_id": 1
}

Successful Response (New User)

json
{
  "data": {
    "user_id": 43,
    "patient_person_id": 81,
    "patient": {
      "id": 100,
      "patient_person_id": 81,
      "profile_shared": false,
      "organization_id": 1,
      "created_at": "2025-01-15T10:00:00Z",
      "updated_at": "2025-01-15T10:00:00Z"
    },
    "person": {
      "id": 81,
      "user_id": 43,
      "name": "John Doe",
      "date_of_birth": null,
      "blood_type": null,
      "allergies": [],
      "chronic_conditions": [],
      "insurance_entries": []
    },
    "user_created": true,
    "organization_added": true,
    "required_forms": [
      {
        "form_id": 500,
        "template_id": 42,
        "title": "Privacy Policy & GDPR Consent",
        "type": "disclaimer",
        "status": "pending",
        "must_sign_before": "first_appointment",
        "consent_types": ["analytics", "marketing"]
      },
      {
        "form_id": 501,
        "template_id": 43,
        "title": "Profile Sharing Consent",
        "type": "disclaimer",
        "consent_types": ["profile_sharing"],
        "status": "pending",
        "must_sign_before": "first_appointment"
      }
    ]
  }
}

Successful Response (Existing User, New Org)

json
{
  "data": {
    "user_id": 43,
    "patient_person_id": 81,
    "patient": {
      "id": 101,
      "patient_person_id": 81,
      "profile_shared": false,
      "organization_id": 2,
      "created_at": "2025-01-16T10:00:00Z",
      "updated_at": "2025-01-16T10:00:00Z"
    },
    "person": {
      "id": 81,
      "user_id": 43,
      "name": "John Doe"
    },
    "user_created": false,
    "organization_added": true,
    "required_forms": [
      {
        "form_id": 510,
        "template_id": 43,
        "title": "Profile Sharing Consent",
        "type": "disclaimer",
        "consent_types": ["profile_sharing"],
        "status": "pending",
        "must_sign_before": "first_appointment"
      }
    ]
  }
}

Note: when the patient already has a patient_persons record (registered at another org), the new org initially sees only their name. The full portable profile (blood type, allergies, insurance, etc.) becomes available after the patient signs the profile-sharing consent form, which sets profile_shared = true on the new patients record.

Response Fields

  • user_id — ID of the user account (new or existing)
  • patient_person_id — ID of the portable patient profile
  • patient — The org-patient link record
  • person — The portable profile (name, DOB, blood type, allergies, etc.)
  • user_createdtrue if a new Clerk account was created
  • organization_addedtrue if user was added to the organization
  • required_forms — Forms that must be signed before the first appointment

Error Handling

Conflict Error (Patient Already Exists in This Org)

json
{
  "error": {
    "code": "patient_already_exists",
    "message": "Patient with this email already exists in this organization",
    "details": {
      "email": "[email protected]",
      "organization_id": 1,
      "existing_patient_id": 100
    }
  }
}

Common Error Codes

CodeHTTPDescription
name_required400Name field is required
email_required400Email field is required
phone_required400Phone field is required
invalid_email_format400Email format is invalid
patient_already_exists409Patient already exists in this organization
clerk_api_error502Clerk API unavailable (user creation failed)

Organization-Level Required Forms (via Automations)

When a patient is onboarded, organizations can use Automation Rules to automatically create required disclaimer forms (GDPR consent, terms of service, etc.). These must be signed before the patient can book their first appointment.

Onboarding Flow with Automation Rules

1. Patient submits onboarding request → POST /v1/patients/onboard

2. System creates:
   └─ patient_persons (profile)
   └─ patients (org link)
   └─ user + Clerk account (if new)

3. System publishes event: patient.onboarded

4. Automation engine executes matching rules
   ├─ Action 1: Create form 500 (Privacy Policy) - blocking
   ├─ Action 2: Create form 501 (Profile Sharing Consent) - blocking
   ├─ Action 3: Create form 502 (Terms of Service) - blocking
   └─ Action 4: Send welcome email

5. Response includes required_forms array

6. Patient fills and signs each form → consents auto-granted
   └─ Signing "Profile Sharing Consent" sets patients.profile_shared = true

7. Patient can now book first appointment (full profile visible to org staff)

See Automations → for how automation rules are configured.



Security & Permissions

Who Can Onboard Patients?

sql
-- Only admins and customer support can onboard new patients
current_app_role() IN ('admin', 'customer_support')

Data Encryption

  • phone is encrypted using AES-256-GCM before storage
  • Stored as phone_encrypted BYTEA on patient_persons (not on patients)
  • emergency_contact_phone is also encrypted (emergency_contact_phone_encrypted BYTEA)
  • Infrastructure encryption (AWS RDS) covers all data at rest

Audit Logging

Every onboarding is logged to audit_log:

json
{
  "action": "CREATE",
  "entity_type": "patient",
  "entity_id": 100,
  "changes": {
    "patient_person_id": 81,
    "organization_id": 1,
    "user_id": 43
  },
  "user_id": 1,
  "created_at": "2025-01-15T10:00:00Z"
}