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
- Superadmin creates organization: A platform operator (superadmin) creates the clinic/practice via
POST /v1/organizations - 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. - Set organization context: All data operations work within that organization's boundary — determined by the domain the user visits
- 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:
- User visits
{slug}.clinic.restartix.proor a custom domain - Frontend middleware resolves the hostname → organization via
GET /v1/public/organizations/resolve?slug={slug} - Organization ID is passed to the Core API via
X-Organization-IDheader - All queries are automatically filtered by
organization_idvia 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.
| Column | Type | Description |
|---|---|---|
id | UUID DEFAULT gen_random_uuid() | Primary key |
name | TEXT | Organization name (e.g., "RestartiX") |
slug | TEXT | URL-safe identifier (unique) |
tagline | TEXT | Short description |
description | TEXT | Full description (Markdown/HTML) |
email | TEXT | Contact email |
phone | TEXT | Contact phone |
website | TEXT | Website URL |
location | TEXT | Physical location |
logo_url | TEXT | Logo image (S3 key) |
icon_url | TEXT | Icon/favicon (S3 key) |
language_code | TEXT | Default UI language (ISO 639-1, e.g. en, ro) |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last 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:
- Require Privacy Policy form (blocking)
- Require Terms of Service form (blocking)
- Send welcome email
- Show "Complete profile" notification
Example: First Video Appointment
When appointment.first_booked AND appointment.type = "video":
- Require video recording consent (blocking)
- Send "What to expect" email
- Create intake questionnaire
Example: Service Plan Enrollment
When service_plan.enrolled:
- Require biometric data consent (for exercise tracking)
- Send program welcome email
- Create baseline assessment form
See:
- Automations feature - Complete lifecycle workflow system with 15+ triggers and 13+ action types
- Patient onboarding - How automation rules execute during onboarding
- Forms feature - Disclaimer forms with
consent_types - GDPR compliance - Two-layer consent architecture
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.
| Column | Type | Description |
|---|---|---|
id | UUID DEFAULT gen_random_uuid() | Primary key |
organization_id | UUID | FK to organizations (CASCADE) |
domain | TEXT | Hostname (lowercased, unique) |
domain_type | enum | clinic or portal |
status | enum | pending / verified / failed |
verification_token | TEXT | Random TXT-record value the admin must set |
verified_at | TIMESTAMPTZ | When the TXT record first matched |
last_check_at | TIMESTAMPTZ | Most recent re-verification attempt |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last update timestamp (auto-updated) |
Per-org
organization_integrations(encrypted third-party credentials) is planned, not shipped — see Planned.
Indexes
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
| Policy | Operation | Rule |
|---|---|---|
org_select | SELECT | id = current_app_org_id() (superadmins use the admin pool and bypass RLS) |
org_update | UPDATE | id = 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
| Policy | Operation | Rule |
|---|---|---|
org_domains_select | SELECT | organization_id = current_app_org_id() plus a public carve-out for verified domains used by the resolve endpoint |
org_domains_insert/update/delete | INSERT/UPDATE/DELETE | current_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):
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:
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:
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:
| Method | Endpoint | Auth | Permission |
|---|---|---|---|
| GET | /v1/public/organizations/resolve?slug=… or ?domain=… | None | — |
| GET | /v1/organizations | Bearer | — (RLS scopes to caller's memberships) |
| POST | /v1/organizations | Bearer | superadmin (platform_roles) |
| GET | /v1/organizations/{id} | Bearer | — (RLS scopes to caller's org) |
| PATCH | /v1/organizations/{id} | Bearer | organizations.update |
| GET | /v1/organizations/{id}/members | Bearer | organizations.manage_members |
| POST | /v1/organizations/{id}/members | Bearer | organizations.manage_members (upsert: re-roles existing members) |
| DELETE | /v1/organizations/{id}/members/{userId} | Bearer | organizations.manage_members |
| GET | /v1/organizations/{id}/domains | Bearer | organizations.manage_domains |
| POST | /v1/organizations/{id}/domains | Bearer | organizations.manage_domains |
| POST | /v1/organizations/{id}/domains/{domainId}/verify | Bearer | organizations.manage_domains |
| DELETE | /v1/organizations/{id}/domains/{domainId} | Bearer | organizations.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)
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
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
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.
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.
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).
Related Features
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:
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_serviceenum 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:
| Table | Description |
|---|---|
specialties | Medical specialties per org |
specialists | Doctors/providers per org |
patients | Patient records per org |
appointments | Appointments per org |
form_templates | Form templates per org |
forms | Form instances per org |
custom_fields | Custom field definitions per org |
custom_field_values | Custom field values per org |
segments | Patient segments per org |
appointment_templates | Appointment types per org |
audit_log | Audit 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
userstable doesn't haveorganization_idbecause users can belong to multiple organizations. User access is managed viauser_organizationsjunction table. patient_personsandpatient_person_managershave noorganization_id— they are patient-owned portable data that travels across orgs. Org access is gated by thepatients.profile_sharedconsent 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.
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADEIf 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:
- Data ownership - Organizations are a core domain entity with complex business logic
- Integration data - Need to store encrypted API keys, settings, branding
- Medical compliance - HIPAA requires full control over data access and audit trail
- Flexibility - Can extend organizations with custom fields, features, billing
- 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:
-- 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:
- RLS performance - Direct column check is faster than sub-query
- Index usage - PostgreSQL can use the index on
organization_iddirectly - 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:
api_key_encrypted BYTEA NOT NULLWhy bytea instead of TEXT:
- Binary format - Encryption output is binary, not text
- No encoding overhead - No need for base64 or hex encoding
- 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
- Encrypted at rest - AES-256-GCM application-level encryption
- Encrypted in transit - TLS 1.2+ for all database connections
- Admin-only access - RLS policies restrict to organization admins
- Rate limited - API endpoint limited to 10 requests/minute
- Audited - All access logged to
audit_log - No caching - Decrypted keys never cached, always decrypt on-demand
Organization Isolation
- RLS at database level - PostgreSQL enforces boundaries
- Session variables - Set per-request, transaction-scoped
- No cross-org queries - Users can only access data for
current_organization_id - Superadmin override - Superadmins can bypass for support (audited)
- Portable profile exception -
patient_personsis 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:
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:
-- 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:
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:
SELECT set_config('app.current_org_id', '1', true); -- ~0.1msThe 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:
Add organization_id column:
sqlCREATE 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() );Add index:
sqlCREATE INDEX idx_new_table_org ON new_table(organization_id);Add RLS policies:
sqlALTER 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') );Add auto-update trigger:
sqlCREATE 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:
Add to enum:
sqlALTER TYPE integration_service ADD VALUE 'new_service';Update application code to handle the new service
Document the service in api-keys.md
Create integration via API:
bashPOST /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:
-- 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 succeedMulti-Organization Testing
Test user switching between organizations:
-- 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 onlyAPI Key Encryption Testing
Test encryption/decryption roundtrip:
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:
- User not connected to organization in
user_organizations - Session variable not updated correctly
- RLS policy blocking access
Debug:
-- 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:
- Encryption key changed in environment
- Associated data mismatch (wrong organization_id)
- Data corruption in database
Debug:
# Check encryption key is set
echo $API_KEY_ENCRYPTION_KEY
# Check it's the same key used for encryption
# Re-encrypt if neededSee 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 DNSToday 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:
- Browser hits
healthcorp.clinic.restartix.pro - Next.js proxy extracts slug
healthcorpfrom hostname - Proxy calls
GET /v1/public/organizations/resolve?slug=healthcorp(public, no auth) - Core API returns org ID, name, slug, logo, icon, language
- Proxy sets
org-id,org-slug,org-nameas httpOnly cookies createApiClient()readsorg-idcookie and sendsX-Organization-IDheaderOrganizationContextmiddleware uses the header to set RLS session variables- 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:
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:
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /v1/organizations/{id}/domains | Admin | List domains for an org (response includes verification_token) |
POST | /v1/organizations/{id}/domains | Admin | Add a custom domain |
DELETE | /v1/organizations/{id}/domains/{domainId} | Admin | Remove a custom domain |
POST | /v1/organizations/{id}/domains/{domainId}/verify | Admin | Trigger DNS verification |
DNS verification flow:
- Admin adds custom domain
clinic.myclinic.comvia API - System generates a UUID verification token
- Admin adds a DNS TXT record:
_restartix-verification.clinic.myclinic.com→{token} - Admin triggers verification — system calls
net.LookupTXTto check - If TXT matches, domain status →
verified,verified_atis set - Daily cron re-verifies; revokes if TXT removed (
status→failed)
Resolve endpoint extension:
The existing GET /v1/public/organizations/resolve gains a domain parameter:
GET /v1/public/organizations/resolve?domain=clinic.myclinic.comLooks 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-keysendpoint, and nointegration_serviceenum in the database today.- Custom per-org roles — the
rolestable 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— keepcurrent_organization_idas "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.prowith 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
- Database Schema → architecture/data-model.md → Area 1 (Foundation) — canonical SQL with indexes and RLS
- API Documentation - All organization API endpoints
- API Keys Guide - Encryption, rotation, and usage
- Authentication - Clerk integration and user management