Skip to content

Organizations Feature

A multi-tenant container for clinic/practice data, branding, and integrations.

What this enables

Multi-clinic operations: Specialists and administrators can manage multiple clinics/practices from a single account, switching between them instantly.

Brand control: Each organization has its own logo, colors, and dedicated domains (e.g., healthcorp.clinic.restartix.pro for staff, healthcorp.portal.restartix.pro for patients). Custom domains are also supported (e.g., clinic.myclinic.com).

Data isolation: Appointments, forms, files, and settings are completely separate per organization — accidentally mixing clinics is impossible. The one deliberate exception is the patient's portable profile (patient_persons): demographics, blood type, allergies, and insurance travel with the patient across clinics, but only after the patient signs a per-org profile-sharing consent form. See Patient Profile Sharing Consent →.

Integration storage: Securely store API keys for payment processors, EHR systems, and other services without exposing secrets in code.

Platform model

RestartiX is a curated platform — clinics cannot self-register. Every organization is created and onboarded by the platform team (superadmin role). This ensures quality control, proper configuration, and compliance vetting before a clinic goes live. Self-service organization creation may be added in the future, but is explicitly out of scope for now.

How it works

  1. Superadmin creates organization: A platform operator (superadmin) creates the clinic/practice via POST /v1/organizations
  2. Members are added: An org admin invites staff via POST /v1/organizations/{id}/members (email + role: admin / specialist / customer_support). Patients are auto-linked at portal sign-up — never via this endpoint.
  3. Set organization context: All data operations work within that organization's boundary — determined by the domain the user visits
  4. Switch organizations (optional): If they work at multiple clinics, they switch by visiting the other clinic's subdomain (or via PUT /v1/me/switch-organization)

Planned (not shipped today): per-org integration credentials (e.g. Stripe API keys) — see the Planned section.


Technical Reference

Overview

Organizations are the root of multi-tenancy in the RestartiX system. Every piece of tenant-scoped data belongs to an organization. Users can belong to multiple organizations and switch between them.

Key Principle: Clerk handles authentication (login, MFA, sessions). Organizations handle tenancy (data isolation, access control).

Key Concepts

Organizations as Multi-Tenancy Root

Every request operates within an organization context determined by the domain:

  1. User visits {slug}.clinic.restartix.pro or a custom domain
  2. Frontend middleware resolves the hostname → organization via GET /v1/public/organizations/resolve?slug={slug}
  3. Organization ID is passed to the Core API via X-Organization-ID header
  4. All queries are automatically filtered by organization_id via Row-Level Security (RLS)

The Golden Rule: WHERE organization_id = current_app_org_id() — no sub-queries, no JOINs (except for the users table which supports multi-org membership).

Organization Slug

Every organization has a unique slug (URL-safe identifier) used for subdomain routing:

  • Clinic app: {slug}.clinic.restartix.pro (e.g., healthcorp.clinic.restartix.pro)
  • Patient Portal: {slug}.portal.restartix.pro (e.g., healthcorp.portal.restartix.pro)
  • Custom domains: Organizations can optionally configure their own domains (e.g., clinic.myclinic.com)
  • Public resolution: GET /v1/public/organizations/resolve?slug={slug} (no auth required)

Example slugs: restartix, healthcorp, medcenter-amsterdam

Organization Switching

Users who belong to multiple organizations switch between them by navigating to the other organization's domain. The OrgSwitcher component in the Clinic app redirects to {slug}.clinic.restartix.pro.

The PUT /v1/me/switch-organization endpoint remains available as a secondary, API-driven path for clients that prefer it over a redirect.

Use cases:

  • Specialist working for multiple clinics
  • Admin managing multiple organizations
  • Support staff with cross-org access

API Keys for External Integrations (planned)

Per-org encrypted credentials for third-party services (Stripe, EHR systems, etc.) are designed but not yet implemented. The data model would store keys encrypted at the application layer (AES-256-GCM via internal/core/crypto); the organization_integrations table is not in any migration today.

See Planned for the full list of org-related work that hasn't shipped.

Database Schema

Tables

organizations

The root table for multi-tenancy.

ColumnTypeDescription
idUUID DEFAULT gen_random_uuid()Primary key
nameTEXTOrganization name (e.g., "RestartiX")
slugTEXTURL-safe identifier (unique)
taglineTEXTShort description
descriptionTEXTFull description (Markdown/HTML)
emailTEXTContact email
phoneTEXTContact phone
websiteTEXTWebsite URL
locationTEXTPhysical location
logo_urlTEXTLogo image (S3 key)
icon_urlTEXTIcon/favicon (S3 key)
language_codeTEXTDefault UI language (ISO 639-1, e.g. en, ro)
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast update timestamp (auto-updated)

Organization Lifecycle Workflows

Organizations use the Automations feature to configure event-driven workflows for patient and appointment lifecycles.

Example: New Patient Onboarding

When patient.onboarded event fires:

  1. Require Privacy Policy form (blocking)
  2. Require Terms of Service form (blocking)
  3. Send welcome email
  4. Show "Complete profile" notification

Example: First Video Appointment

When appointment.first_booked AND appointment.type = "video":

  1. Require video recording consent (blocking)
  2. Send "What to expect" email
  3. Create intake questionnaire

Example: Service Plan Enrollment

When service_plan.enrolled:

  1. Require biometric data consent (for exercise tracking)
  2. Send program welcome email
  3. Create baseline assessment form

See:

organization_domains

Stores additional verified hostnames for an org (custom subdomains beyond {slug}.clinic.restartix.pro). Defined in services/api/migrations/core/000002_tenancy_rbac.up.sql.

ColumnTypeDescription
idUUID DEFAULT gen_random_uuid()Primary key
organization_idUUIDFK to organizations (CASCADE)
domainTEXTHostname (lowercased, unique)
domain_typeenumclinic or portal
statusenumpending / verified / failed
verification_tokenTEXTRandom TXT-record value the admin must set
verified_atTIMESTAMPTZWhen the TXT record first matched
last_check_atTIMESTAMPTZMost recent re-verification attempt
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast update timestamp (auto-updated)

Per-org organization_integrations (encrypted third-party credentials) is planned, not shipped — see Planned.

Indexes

sql
CREATE INDEX idx_orgs_slug ON organizations(slug);
CREATE INDEX idx_org_domains_org ON organization_domains(organization_id);
CREATE UNIQUE INDEX idx_org_domains_domain ON organization_domains(domain);

Every organization_id column across the entire schema has an index for fast RLS checks.

Row-Level Security (RLS)

RLS policies enforce organization boundaries at the database level.

Organizations Table

PolicyOperationRule
org_selectSELECTid = current_app_org_id() (superadmins use the admin pool and bypass RLS)
org_updateUPDATEid = current_app_org_id() AND current_app_has_permission('organizations','update')

Inserts and deletes are owner-pool only (superadmin via the admin pool); there are no INSERT/DELETE policies on organizations.

Organization Domains Table

PolicyOperationRule
org_domains_selectSELECTorganization_id = current_app_org_id() plus a public carve-out for verified domains used by the resolve endpoint
org_domains_insert/update/deleteINSERT/UPDATE/DELETEcurrent_app_has_permission('organizations','manage_domains') and the row's org matches the current org

How RLS Works

Every authenticated request sets PostgreSQL session variables (UUIDs, not integers):

sql
SELECT set_config('app.current_user_id', '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001', true);
SELECT set_config('app.current_org_id',  '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100', true);
SELECT set_config('app.current_role',    'specialist', true);

RLS policies use helper functions to read these variables:

sql
CREATE OR REPLACE FUNCTION current_app_org_id() RETURNS UUID AS $$
    SELECT NULLIF(current_setting('app.current_org_id', true), '')::UUID;
$$ LANGUAGE SQL STABLE;
-- Note: No is_superadmin() function. Superadmins bypass RLS via AdminPool (owner role).

Then policies check these values:

sql
CREATE POLICY org_select ON organizations FOR SELECT USING (
    id = current_app_org_id()
);

Result: Users on the AppPool can only see data for their current organization, automatically enforced by PostgreSQL. Superadmins use the AdminPool (owner role) which bypasses RLS entirely.

See architecture/data-model.md → Area 1 (Foundation) for the canonical schema.

API Reference

Endpoints

The full list — gates listed verbatim with the permission code enforced by RequirePermission:

MethodEndpointAuthPermission
GET/v1/public/organizations/resolve?slug=… or ?domain=…None
GET/v1/organizationsBearer— (RLS scopes to caller's memberships)
POST/v1/organizationsBearersuperadmin (platform_roles)
GET/v1/organizations/{id}Bearer— (RLS scopes to caller's org)
PATCH/v1/organizations/{id}Bearerorganizations.update
GET/v1/organizations/{id}/membersBearerorganizations.manage_members
POST/v1/organizations/{id}/membersBearerorganizations.manage_members (upsert: re-roles existing members)
DELETE/v1/organizations/{id}/members/{userId}Bearerorganizations.manage_members
GET/v1/organizations/{id}/domainsBearerorganizations.manage_domains
POST/v1/organizations/{id}/domainsBearerorganizations.manage_domains
POST/v1/organizations/{id}/domains/{domainId}/verifyBearerorganizations.manage_domains
DELETE/v1/organizations/{id}/domains/{domainId}Bearerorganizations.manage_domains

Listing members today reuses organizations.manage_members (no separate "view members" permission). All gated routes also enforce the same code via current_app_has_permission(...) in RLS — the Go middleware is a fast-fail layer, the database is the authoritative one.

Examples

All examples below use UUIDs; the platform has no integer IDs anywhere on the wire.

Resolve organization by slug (public, for domain routing)

bash
GET /v1/public/organizations/resolve?slug=restartix

Response:
{
  "data": {
    "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
    "name": "RestartiX",
    "slug": "restartix",
    "logo_url": "https://s3.../logo.png",
    "icon_url": "https://s3.../icon.png",
    "language_code": "en"
  }
}

List user's organizations

bash
GET /v1/organizations
Authorization: Bearer <clerk_token>

Response:
{
  "data": [
    { "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100", "name": "RestartiX", "slug": "restartix" },
    { "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f200", "name": "HealthCorp", "slug": "healthcorp" }
  ]
}

Switch active organization

bash
PUT /v1/me/switch-organization
Authorization: Bearer <clerk_token>
Content-Type: application/json

{
  "organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f200"
}

Response:
{
  "data": {
    "current_organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f200",
    "message": "Organization switched successfully"
  }
}

Update organization

Requires organizations.update permission. Method is PATCH, not PUT — only fields present in the body are updated.

bash
PATCH /v1/organizations/0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100
Authorization: Bearer <clerk_token>
Content-Type: application/json

{
  "name": "RestartiX NL",
  "email": "[email protected]"
}

Response:
{
  "data": {
    "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
    "name": "RestartiX NL",
    "slug": "restartix",
    "email": "[email protected]",
    "updated_at": "2026-04-26T14:22:00Z"
  }
}

Add a member

Requires organizations.manage_members. The endpoint is upsert-style: if the user is already a member, their role is re-assigned to the requested one. The role value must be one of the assignable codes (admin, specialist, customer_support) — patient is auto-assigned at portal sign-up and cannot be granted here.

bash
POST /v1/organizations/0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100/members
Authorization: Bearer <clerk_token>
Content-Type: application/json

{
  "email": "[email protected]",
  "role": "specialist"
}

Response:
{
  "data": {
    "user_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f300",
    "email": "[email protected]",
    "organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
    "role_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f400",
    "role_code": "specialist"
  }
}

The audit row uses entity_type = "user_organization" with action CREATE for new memberships, UPDATE for role changes, and is omitted entirely for no-op upserts.

See api.md for the full endpoint reference (members, domains, request/response shapes, error codes).

Authentication

  • Clerk handles all authentication (login, signup, MFA, sessions)
  • Users can belong to multiple organizations
  • Role-based access control (patient, specialist, admin, customer_support, superadmin)

See ../auth/index.md for authentication documentation.

User Organizations

The user_organizations table manages many-to-many relationships:

sql
CREATE TABLE user_organizations (
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, organization_id)
);

Use cases:

  • Multi-clinic specialists
  • Consultants working across organizations
  • Support staff with cross-org access
  • Patients switching providers

Integrations

Organizations integrate with external services via encrypted API keys:

  • (No integrations defined yet — extend the integration_service enum as integrations are added, e.g. payment processors, EHR systems)

Each integration stores:

  • Service identifier (enum)
  • Human-readable title
  • Encrypted API key (AES-256-GCM, stored as bytea)

See api-keys.md for integration documentation.

Organization-Scoped Data

Every tenant-scoped table has organization_id:

TableDescription
specialtiesMedical specialties per org
specialistsDoctors/providers per org
patientsPatient records per org
appointmentsAppointments per org
form_templatesForm templates per org
formsForm instances per org
custom_fieldsCustom field definitions per org
custom_field_valuesCustom field values per org
segmentsPatient segments per org
appointment_templatesAppointment types per org
audit_logAudit events per org

All automatically filtered by RLS based on current_organization_id.

Design Principles

1. organization_id on Every Tenant-Scoped Table

Why: Direct RLS checks, no sub-queries, maximum performance.

Exceptions:

  • The users table doesn't have organization_id because users can belong to multiple organizations. User access is managed via user_organizations junction table.
  • patient_persons and patient_person_managers have no organization_id — they are patient-owned portable data that travels across orgs. Org access is gated by the patients.profile_shared consent flag (application-layer enforcement). See Patients →.

2. Clean Table Naming

Why: Human-readable, predictable, easy to understand.

  • organizations, users, appointments
  • auth_organizations, system_users, appt_v2

3. Explicit Foreign Keys with ON DELETE Behavior

Why: Referential integrity, no orphaned records, clear cascade behavior.

sql
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE

If an organization is deleted, all related data is automatically deleted.

4. Encryption for High-Sensitivity Fields

Why: Defense in depth. API keys and phone numbers get application-level AES-256-GCM encryption in addition to infrastructure encryption (AWS RDS).

Not encrypted: Form values, custom field values (need to be queryable for segments).

5. Timestamps Everywhere

Why: Audit trail, debugging, analytics.

Every table has:

  • created_at (set once on INSERT)
  • updated_at (auto-updated on UPDATE via trigger)

6. UUIDs for External-Facing IDs

Why: Security (prevent enumeration), external API integration.

  • Internal IDs: bigserial (fast, indexed)
  • Public IDs: uid UUID (appointments, etc.)

Organizations use slug instead of UUID for human-friendly URLs.

7. Own Organizations, Not Clerk Orgs

Why: Clerk handles auth only. Organizations are a core domain concept with business logic (integrations, settings, branding, billing, etc.).

Clerk's organization feature is optional and not used. Our organizations table is the source of truth.

8. Local Audit + Telemetry Forwarding

Why: HIPAA safety net. All audit events written synchronously to local audit_log, then forwarded asynchronously to Telemetry service for enrichment.

Guarantees: Zero audit event loss, even if Telemetry service is down.

Architecture Decisions

Why Not Use Clerk Organizations?

Clerk offers an organizations feature, but we chose to build our own:

Reasons:

  1. Data ownership - Organizations are a core domain entity with complex business logic
  2. Integration data - Need to store encrypted API keys, settings, branding
  3. Medical compliance - HIPAA requires full control over data access and audit trail
  4. Flexibility - Can extend organizations with custom fields, features, billing
  5. RLS integration - Direct PostgreSQL integration for row-level security

Clerk handles: Authentication (login, MFA, sessions) Our organizations handle: Tenancy (data isolation, settings, integrations)

Why Denormalize organization_id?

Many tables have organization_id even when it could be derived via JOIN:

sql
-- Example: specialist_specialties has organization_id
-- Could derive from specialists.organization_id
-- But we store it directly for RLS performance
CREATE TABLE specialist_specialties (
    specialist_id   UUID NOT NULL REFERENCES specialists(id),
    specialty_id    UUID NOT NULL REFERENCES specialties(id),
    organization_id UUID NOT NULL REFERENCES organizations(id),  -- Denormalized!
    PRIMARY KEY (specialist_id, specialty_id)
);

Why:

  1. RLS performance - Direct column check is faster than sub-query
  2. Index usage - PostgreSQL can use the index on organization_id directly
  3. Simplicity - No complex sub-query logic in RLS policies

Trade-off: Slight denormalization for massive performance gain.

Why bytea for Encrypted API Keys?

API keys are encrypted at the application level (AES-256-GCM) and stored as binary data:

sql
api_key_encrypted BYTEA NOT NULL

Why bytea instead of TEXT:

  1. Binary format - Encryption output is binary, not text
  2. No encoding overhead - No need for base64 or hex encoding
  3. Integrity - Binary data can't be accidentally modified by text operations

Storage format: [nonce (12 bytes)][ciphertext][auth tag (16 bytes)]

See api-keys.md for encryption details.

Security Considerations

RLS Enforcement

RLS policies are enabled on all tenant-scoped tables. The AppPool connects as restartix_app role which does NOT bypass RLS. The AdminPool (restartix owner) is used only by auth middleware, superadmin requests, and system queries — it bypasses RLS by design.

Critical: Never grant BYPASSRLS to the restartix_app role.

API Key Security

  1. Encrypted at rest - AES-256-GCM application-level encryption
  2. Encrypted in transit - TLS 1.2+ for all database connections
  3. Admin-only access - RLS policies restrict to organization admins
  4. Rate limited - API endpoint limited to 10 requests/minute
  5. Audited - All access logged to audit_log
  6. No caching - Decrypted keys never cached, always decrypt on-demand

Organization Isolation

  1. RLS at database level - PostgreSQL enforces boundaries
  2. Session variables - Set per-request, transaction-scoped
  3. No cross-org queries - Users can only access data for current_organization_id
  4. Superadmin override - Superadmins can bypass for support (audited)
  5. Portable profile exception - patient_persons is readable by any org the patient is registered at, but extended fields (DOB, allergies, etc.) are only exposed to org staff after the patient signs a profile-sharing consent form (patients.profile_shared = true). Enforcement is application-level, not RLS.

Audit Trail

All organization operations are logged to audit_log:

  • Organization created/updated/deleted
  • User added/removed from organization
  • Organization switched by user
  • API keys accessed/rotated
  • Settings changed

Performance Considerations

Index Coverage

Every organization_id column has an index:

sql
CREATE INDEX idx_specialists_org ON specialists(organization_id);
CREATE INDEX idx_appointments_org ON appointments(organization_id);
CREATE INDEX idx_forms_org ON forms(organization_id);
-- ... etc.

Why: RLS policies filter by organization_id on every query. Indexes make this fast.

RLS Policy Design

RLS policies use direct column checks, not sub-queries:

sql
-- Fast (direct check)
CREATE POLICY specialists_select ON specialists FOR SELECT USING (
    organization_id = current_app_org_id()
);

-- Slow (sub-query - avoid!)
CREATE POLICY specialists_select ON specialists FOR SELECT USING (
    id IN (
        SELECT specialist_id FROM some_other_table
        WHERE organization_id = current_app_org_id()
    )
);

Exception: The users table uses a sub-query because users belong to multiple orgs:

sql
CREATE POLICY users_select ON users FOR SELECT USING (
    id = current_app_principal_id()
    OR id IN (
        SELECT user_id FROM user_organizations
        WHERE organization_id = current_app_org_id()
    )
);

This is acceptable because user_organizations is small and has an index on organization_id.

Session Variable Overhead

Setting session variables per-request has minimal overhead:

sql
SELECT set_config('app.current_org_id', '1', true);  -- ~0.1ms

The true flag makes the setting transaction-scoped (not session-scoped), so it auto-resets after each request.

Migration Guide

Adding a New Table

When creating a new tenant-scoped table:

  1. Add organization_id column:

    sql
    CREATE TABLE new_table (
        id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
        -- ... other columns
        created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
  2. Add index:

    sql
    CREATE INDEX idx_new_table_org ON new_table(organization_id);
  3. Add RLS policies:

    sql
    ALTER TABLE new_table ENABLE ROW LEVEL SECURITY;
    
    -- Superadmins bypass RLS via AdminPool (owner role) — no is_superadmin() needed
    CREATE POLICY new_table_select ON new_table FOR SELECT USING (
        organization_id = current_app_org_id()
    );
    
    -- Gate mutations on a permission, never a role-string compare. Seed the
    -- permission in the same migration and grant it to the system role
    -- templates that should have it (CLAUDE.md → RBAC, apps/docs/reference/rbac-permissions.md).
    CREATE POLICY new_table_modify ON new_table FOR ALL USING (
        organization_id = current_app_org_id()
        AND current_app_has_permission('new_table', 'manage')
    );
  4. Add auto-update trigger:

    sql
    CREATE TRIGGER set_updated_at BEFORE UPDATE ON new_table
        FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();

Adding a New Integration Service

To add support for a new external service:

  1. Add to enum:

    sql
    ALTER TYPE integration_service ADD VALUE 'new_service';
  2. Update application code to handle the new service

  3. Document the service in api-keys.md

  4. Create integration via API:

    bash
    POST /v1/organizations/1/integrations
    {
      "title": "New Service Production",
      "service": "new_service",
      "api_key": "sk_live_..."
    }

Testing

RLS Policy Testing

Test RLS policies by setting session variables:

sql
-- Set user context (non-admin)
SELECT set_config('app.current_user_id', '42', true);
SELECT set_config('app.current_org_id', '1', true);
SELECT set_config('app.current_role', 'specialist', true);

-- Should only see org 1
SELECT * FROM organizations;

-- Should not be able to update
UPDATE organizations SET name = 'Hacked' WHERE id = 1;  -- Should fail

-- Switch to admin
SELECT set_config('app.current_role', 'admin', true);

-- Should now be able to update
UPDATE organizations SET name = 'Updated' WHERE id = 1;  -- Should succeed

Multi-Organization Testing

Test user switching between organizations:

sql
-- User belongs to orgs 1 and 3
INSERT INTO user_organizations (user_id, organization_id)
VALUES (42, 1), (42, 3);

-- Set to org 1
SELECT set_config('app.current_org_id', '1', true);
SELECT * FROM appointments;  -- Should see org 1 appointments only

-- Switch to org 3
SELECT set_config('app.current_org_id', '3', true);
SELECT * FROM appointments;  -- Should see org 3 appointments only

API Key Encryption Testing

Test encryption/decryption roundtrip:

go
func TestAPIKeyEncryption(t *testing.T) {
    orgID := int64(1)
    plaintext := "sk_live_test_key_123"

    // Encrypt
    encrypted, err := crypto.EncryptAPIKey(orgID, plaintext)
    require.NoError(t, err)

    // Decrypt
    decrypted, err := crypto.DecryptAPIKey(orgID, encrypted)
    require.NoError(t, err)

    // Verify
    assert.Equal(t, plaintext, decrypted)
}

Troubleshooting

Users Can't See Data After Switching Orgs

Symptom: User switches organization but sees empty lists.

Causes:

  1. User not connected to organization in user_organizations
  2. Session variable not updated correctly
  3. RLS policy blocking access

Debug:

sql
-- Check user's organization memberships
SELECT organization_id FROM user_organizations WHERE user_id = 42;

-- Check current session variable
SELECT current_setting('app.current_org_id', true);

-- Check RLS policy (run as superuser)
SET ROLE postgres;
SELECT * FROM appointments WHERE organization_id = 1;

API Keys Not Decrypting

Symptom: Error decrypting API key, invalid auth tag.

Causes:

  1. Encryption key changed in environment
  2. Associated data mismatch (wrong organization_id)
  3. Data corruption in database

Debug:

bash
# Check encryption key is set
echo $API_KEY_ENCRYPTION_KEY

# Check it's the same key used for encryption
# Re-encrypt if needed

See api-keys.md for more troubleshooting.

Domain Mapping

How Domain Resolution Works

Organization context is request-scoped — determined by the domain the user visits, not stored on the user record. This is the full flow for every page load:

  Browser ──► healthcorp.clinic.restartix.pro    (subdomain)
         OR   clinic.myclinic.com                (custom domain)


  ┌──────────────────────────────────────────────────────────┐
  │  Next.js Proxy (proxy.ts)                                │
  │                                                          │
  │  extractSlugFromHostname(hostname, CLINIC_DOMAIN)        │
  │    ├── Match? slug = "healthcorp"                        │
  │    │   └── resolveOrganization(slug, CORE_API_URL)       │
  │    │       GET /v1/public/organizations/resolve           │
  │    │           ?slug=healthcorp                           │
  │    │                                                     │
  │    └── No match? (custom domain)                         │
  │        └── resolveOrganizationByDomain(hostname, ...)    │
  │            GET /v1/public/organizations/resolve           │
  │                ?domain=clinic.myclinic.com                │
  │                                                          │
  │  Set httpOnly cookies: org-id, org-slug, org-name        │
  │  Enforce Clerk auth for protected routes                 │
  └──────────────────────────┬───────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  Core API — Public Resolve Endpoint (no auth)            │
  │                                                          │
  │  ?slug=healthcorp                                        │
  │    ├── Redis cache hit? (org:resolve:slug:healthcorp)    │
  │    │   └── Return cached org                             │
  │    └── Cache miss?                                       │
  │        └── repo.FindBySlugPublic (pool, no RLS)          │
  │            └── Cache result in Redis (5 min TTL)         │
  │                                                          │
  │  ?domain=clinic.myclinic.com                             │
  │    ├── Redis cache hit? (org:resolve:domain:clinic.my..) │
  │    │   └── Return cached org                             │
  │    └── Cache miss?                                       │
  │        └── repo.FindByDomainPublic (pool, no RLS)        │
  │            JOIN organization_domains (status='verified')  │
  │            └── Cache result in Redis (5 min TTL)         │
  │                                                          │
  │  Returns: { id, name, slug, logo_url, icon_url, lang }  │
  └──────────────────────────────────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  Server Component / API Route                            │
  │                                                          │
  │  createApiClient() reads org-id cookie                   │
  │  ApiClient sends X-Organization-ID: {org-id} header      │
  └──────────────────────────┬───────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  Core API — Authenticated Request                        │
  │                                                          │
  │  1. Authenticate middleware — verify JWT                     │
  │  2. OrganizationContext middleware:                       │
  │     ├── Read X-Organization-ID header                    │
  │     ├── Validate user is member of this org              │
  │     └── SET app.current_org_id, app.current_role (RLS)   │
  │  3. Handler executes — all queries scoped to org via RLS │
  └──────────────────────────────────────────────────────────┘

Cache invalidation: When an org is updated (PATCH /v1/organizations/{id}), both the slug cache and all verified domain caches for that org are invalidated. Domain caches are also invalidated when a domain is added, removed, or its verification status changes.

Custom Domain Verification Flow

  Admin adds domain via POST /v1/organizations/{id}/domains
  ├── { "domain": "clinic.myclinic.com", "domain_type": "clinic" }
  ├── System generates UUID verification token
  └── Returns: status="pending", verification instructions


  Admin adds DNS TXT record:
    _restartix-verification.clinic.myclinic.com → {token}


  Admin triggers POST /v1/organizations/{id}/domains/{domainId}/verify
  ├── System calls net.LookupTXT("_restartix-verification.clinic.myclinic.com")
  ├── Match found? → status="verified", domain is now usable
  └── No match?    → status="failed", admin retries after fixing DNS

Today vs Planned

The organization feature is the most-shipped surface on the platform. The Today section below describes behavior you can rely on right now; the Planned section is design that has not landed yet and may change.

Today: Subdomain Routing

Each organization is accessed via its slug-based subdomain:

  • Clinic app: {slug}.clinic.restartix.pro
  • Patient Portal: {slug}.portal.restartix.pro
  • Local dev: {slug}.clinic.localhost:9100 / {slug}.portal.localhost:9200

How it works:

  1. Browser hits healthcorp.clinic.restartix.pro
  2. Next.js proxy extracts slug healthcorp from hostname
  3. Proxy calls GET /v1/public/organizations/resolve?slug=healthcorp (public, no auth)
  4. Core API returns org ID, name, slug, logo, icon, language
  5. Proxy sets org-id, org-slug, org-name as httpOnly cookies
  6. createApiClient() reads org-id cookie and sends X-Organization-ID header
  7. OrganizationContext middleware uses the header to set RLS session variables
  8. All downstream queries are scoped to that organization

Organization switching: The OrgSwitcher component redirects to {slug}.{CLINIC_DOMAIN} — a full page navigation to the other org's subdomain. The PUT /v1/me/switch-organization endpoint is the secondary, API-driven path for clients that prefer it.

Today: Custom Domain Support

Organizations can configure their own domains in addition to the default subdomains.

Database: New organization_domains table:

sql
CREATE TYPE domain_type AS ENUM ('clinic', 'portal');
CREATE TYPE domain_status AS ENUM ('pending', 'verified', 'failed');

CREATE TABLE organization_domains (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    domain          TEXT NOT NULL UNIQUE,
    domain_type     domain_type NOT NULL,
    status          domain_status NOT NULL DEFAULT 'pending',
    verification_token TEXT NOT NULL,
    verified_at        TIMESTAMPTZ,
    last_check_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

RLS policies: admin + superadmin can manage domains for their organization.

API endpoints:

MethodPathAuthDescription
GET/v1/organizations/{id}/domainsAdminList domains for an org (response includes verification_token)
POST/v1/organizations/{id}/domainsAdminAdd a custom domain
DELETE/v1/organizations/{id}/domains/{domainId}AdminRemove a custom domain
POST/v1/organizations/{id}/domains/{domainId}/verifyAdminTrigger DNS verification

DNS verification flow:

  1. Admin adds custom domain clinic.myclinic.com via API
  2. System generates a UUID verification token
  3. Admin adds a DNS TXT record: _restartix-verification.clinic.myclinic.com{token}
  4. Admin triggers verification — system calls net.LookupTXT to check
  5. If TXT matches, domain status → verified, verified_at is set
  6. Daily cron re-verifies; revokes if TXT removed (statusfailed)

Resolve endpoint extension:

The existing GET /v1/public/organizations/resolve gains a domain parameter:

GET /v1/public/organizations/resolve?domain=clinic.myclinic.com

Looks up verified custom domains in organization_domains. Frontend proxy uses ?slug= for platform subdomains, ?domain= for custom domains.

Caching: Redis-based cache for domain resolution (5-minute TTL), invalidated on org update or domain status change.

Planned (not shipped today)

  • organization_integrations — per-org encrypted API keys (Stripe, EHR systems, …). Schema and helper exist conceptually; no migration, no /api-keys endpoint, and no integration_service enum in the database today.
  • Custom per-org roles — the roles table can hold non-system rows; the API to manage them and the RLS policies to allow per-org role mutation are not built. System role templates (admin, specialist, customer_support, patient) are the only roles used today.
  • Deprecate PUT /v1/me/switch-organization — keep current_organization_id as "last visited org" for redirect-on-login scenarios.
  • Org-branded auth pages — pass org branding (logo, colors) via cookies to Clerk sign-in/sign-up pages for white-label login screens.
  • Dynamic CORS — allow *.clinic.restartix.pro, *.portal.restartix.pro, and all verified custom domains.
  • Clerk multi-domain SSO — configure Clerk to support auth across all org subdomains and custom domains.
  • Wildcard DNS + SSL*.clinic.restartix.pro / *.portal.restartix.pro with wildcard certificates; Let's Encrypt for custom domains via DNS-01 or HTTP-01 challenge.
  • Infrastructure — CloudFront with custom domain support, ACM for certificate management.

Further Reading