Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesOrganizationsOrganizations — Data Model

Organizations — Data Model

Primary table: organizations in libs/db/src/lib/schema/organizations.ts.

organizations

ColumnTypeNotes
iduuid PKdefaultRandom()
namevarchar(255) NOT NULLDisplay.
slugvarchar(255) UNIQUE NOT NULLAuto-generated slugify(name) + '-' + random(6). Used in public URLs.
descriptiontext
typeorganization_type enum NOT NULL default physical`physical
logo_urltextEither an R2 key starting with orgs/ (presigned on read) or a full URL (passed through).
website, contact_email, contact_phonetext / varcharAll optional. Empty strings normalized to null on PATCH.
timezonevarchar(100) NOT NULL default 'UTC'IANA tz name. Drives schedule formatting and bulk-delete start-time matching.
currencyvarchar(3) NOT NULL default 'USD'ISO 4217 (not enforced at API layer).
platform_tierplatform_tier enum NOT NULL default lite`lite
platform_tier_updated_attimestamptzSet by platform-billing module on tier changes.
clerk_organization_idvarchar(255) UNIQUEClerk-side org id. Set on create; never edited.
provider_account_idvarchar(255)Payment provider account identifier (vault id, etc.).
cancellation_window_hoursint NOT NULL default 2Hours before session start; member self-cancel rejected past this unless allow_late_cancellation.
allow_late_cancellationbool NOT NULL default falseIf true, late cancels permitted but no credit refund.
is_activebool NOT NULL default trueNo code path observed that flips this.
created_at / updated_attimestamptz
deleted_attimestamptzSoft-delete column; not written by any service path.

Constraints / indexes

  • UNIQUE: slug, clerk_organization_id.
  • No additional indexes declared (TODO: verify whether (is_active) etc. need one at scale).

Relations (Drizzle)

  • memberships — many.
  • invitations — many.
  • locations — many (FK direct).
  • programs — many (FK direct).
  • feed_items, plans, payment_provider_configs, payment_transactions, member_profiles, import_provider_configs, import_jobs, export_jobs — all many.
  • minisite_content — one (TODO: verify unique(organization_id)).

Lifecycle

  1. Created by OrganizationsService.create:
    • Clerk side: clerk.organizations.createOrganization({name, slug, createdBy}).
    • DB side: insert organization row with defaults; insert owner memberships row.
  2. Updated via PATCH /:id; partial fields. No Clerk side update from this service (Clerk-side org name/etc. would need separate sync — not implemented; TODO: verify whether out-of-sync is a real problem in prod).
  3. Tier transitions happen elsewhere (apps/api/src/platform-billing/).
  4. Logo updates mutate logo_url to an R2 key.
  5. No deletion path exposed at this layer.

Multi-org isolation pattern

organizations.id is the boundary every other table’s organization_id (or transitive FK chain) points to. The membership table is the only path a user has to read or write into an org’s data:

clerkId (request) → users (via clerk_id) → memberships (active, in orgId) → organization

MembershipsService.requireMembership(orgId, clerkId) is the canonical guard. It runs SELECT … WHERE org_id=? AND user_id=? AND status='active', with a 30s per-process cache.

Soft-delete vs hard-delete

  • Soft: deleted_at column on organizations but no service writes it.
  • Hard: not exposed. Permanent removal would require manual SQL across all child tables — there is no cascading FK setup for organizations.id.

Clerk sync

SideFieldWhen
FitKit → Clerknew orgOrganizationsService.create
FitKit → Clerkname updatesNot synced; TODO: verify whether intentional
Clerk → FitKitnoneNo Clerk org webhooks consumed

Practical effect: Clerk org names can drift from FitKit names if owners rename in either side. The clerk_organization_id link survives renames.