Organizations — Data Model
Primary table: organizations in libs/db/src/lib/schema/organizations.ts.
organizations
| Column | Type | Notes |
|---|---|---|
id | uuid PK | defaultRandom() |
name | varchar(255) NOT NULL | Display. |
slug | varchar(255) UNIQUE NOT NULL | Auto-generated slugify(name) + '-' + random(6). Used in public URLs. |
description | text | |
type | organization_type enum NOT NULL default physical | `physical |
logo_url | text | Either an R2 key starting with orgs/ (presigned on read) or a full URL (passed through). |
website, contact_email, contact_phone | text / varchar | All optional. Empty strings normalized to null on PATCH. |
timezone | varchar(100) NOT NULL default 'UTC' | IANA tz name. Drives schedule formatting and bulk-delete start-time matching. |
currency | varchar(3) NOT NULL default 'USD' | ISO 4217 (not enforced at API layer). |
platform_tier | platform_tier enum NOT NULL default lite | `lite |
platform_tier_updated_at | timestamptz | Set by platform-billing module on tier changes. |
clerk_organization_id | varchar(255) UNIQUE | Clerk-side org id. Set on create; never edited. |
provider_account_id | varchar(255) | Payment provider account identifier (vault id, etc.). |
cancellation_window_hours | int NOT NULL default 2 | Hours before session start; member self-cancel rejected past this unless allow_late_cancellation. |
allow_late_cancellation | bool NOT NULL default false | If true, late cancels permitted but no credit refund. |
is_active | bool NOT NULL default true | No code path observed that flips this. |
created_at / updated_at | timestamptz | |
deleted_at | timestamptz | Soft-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: verifyunique(organization_id)).
Lifecycle
- Created by
OrganizationsService.create:- Clerk side:
clerk.organizations.createOrganization({name, slug, createdBy}). - DB side: insert organization row with defaults; insert owner
membershipsrow.
- Clerk side:
- 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). - Tier transitions happen elsewhere (
apps/api/src/platform-billing/). - Logo updates mutate
logo_urlto an R2 key. - 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) → organizationMembershipsService.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_atcolumn onorganizationsbut 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
| Side | Field | When |
|---|---|---|
| FitKit → Clerk | new org | OrganizationsService.create |
| FitKit → Clerk | name updates | Not synced; TODO: verify whether intentional |
| Clerk → FitKit | none | No 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.