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

Organizations — Behavior

State

No explicit status enum. Lifecycle is conveyed by:

FieldSemantic
is_active=trueDefault; org operates normally.
is_active=falseInactive. (No code path observed that flips this off — TODO: verify.)
deleted_at IS NOT NULLSoft-deleted. Not currently set by any service path.
platform_tier ∈ {lite, pro, elite}Commercial tier. Drives feature gates and entity caps.

platform_tier_updated_at tracks the most recent tier change (set by the platform-billing module, not by this one).

Invariants

  • One Clerk org per FitKit orgclerk_organization_id UNIQUE.
  • Slug uniquenessslug UNIQUE NOT NULL. Auto-generated as slugify(name) + '-' + 6char-random.
  • Caller becomes owner on createPOST /organizations always inserts a memberships row with role='owner', status='active'.
  • Owner protection on membership update — flipping the last owner away from owner or to suspended/cancelled is rejected by MembershipsService.updateMembership. (No similar guard on DELETE /users/me self-delete; see Known Gaps in README.)
  • Onboarding-complete is owner-only — even admins are rejected.
  • Logo upload size 2MB max, MIME ∈ {jpeg, png, webp}. The r2Key is fixed to orgs/${orgId}/logo. Existing logos are overwritten in place.
  • Defense-in-depth on logo confirm — confirm checks both the Redis upload-session blob (signals upload was initiated) AND r2.objectExists(r2Key) (signals the bytes actually landed).
  • Timezone default: UTC (overrideable on create). Currency default: USD.
  • Cancellation policy defaults: cancellationWindowHours=2, allowLateCancellation=false.

Golden paths

G1 — Owner onboarding (create org)

  1. Wizard step “org”: user enters name + org type. (See onboarding.)
  2. Wizard handleFinish calls POST /organizations with {name, type}.
  3. Service:
    • Calls usersService.findOrCreateFromClerk(clerkId) to ensure the caller has a DB row.
    • Calls clerk.organizations.createOrganization({name, slug, createdBy: clerkId}) — the Clerk side adds the caller as org:admin automatically.
    • Inserts organizations row with default tier lite.
    • Inserts owner memberships row.
  4. Legal consents POST (/legal/consents) immediately follows.
  5. Wizard calls POST /:id/onboarding-complete with empty body (default — no class type, no invites) and the welcome email is sent.

G2 — Update branding

  1. PATCH /organizations/:id with {name, description, contactEmail, contactPhone, website, logoUrl}.
  2. All fields conditional on !== undefined; empty string for contactEmail/Phone/website/logoUrl is normalized to null.

G3 — Logo upload

  1. Web: user picks file. POST /:id/logo/upload-url with {mimeType, fileSize}.
  2. Server validates MIME + size; generates presigned R2 PUT URL; stashes {r2Key, mimeType} in Redis under upload:logo:${orgId} with TTL 600s.
  3. Web: PUTs the file directly to R2 with the presigned URL.
  4. Web: POST /:id/logo/confirm. Server verifies the Redis blob and r2.objectExists; UPDATEs logo_url = orgs/${orgId}/logo; invalidates cached presigned read URL; deletes the Redis blob.

G4 — Complete onboarding

  1. POST /organizations/:id/onboarding-complete with optional {classTypeName, defaultDurationMin, defaultCapacity, inviteEmails}.
  2. If classTypeName: find the org’s first program; insert a class type under it.
  3. If inviteEmails: loop and call MembershipsService.createInvitation for each.
  4. Send welcome email via the welcome template (apps/api/src/notifications/templates/welcome.ts). Pulls owner name from Clerk.
  5. Returns { classTypeCreated, invitationsSent, emailSent }.

Edge cases & error states

ScenarioBehavior
POST /organizations with name that slugifies to existing slugRandom 6-char suffix prevents collision in practice; theoretical race could fail with a UNIQUE violation (not handled).
Clerk createOrganization failsThe DB org row is never created (Clerk call happens first); error bubbles.
Logo upload-url with disallowed MIME400 ‘Unsupported file type. Use JPEG, PNG, or WebP.’.
Logo upload-url with size > 2MB400 ‘File too large (max 2MB)’.
Logo confirm without prior upload-url (Redis blob expired)400 ‘Upload session expired. Please try again.’.
Logo confirm but file never PUT to R2400 ‘Upload not found. Please re-upload the file.’.
Coach/member tries to update org403 ‘Only owners and admins can update the organization’.
Non-owner calls onboarding-complete403 ‘Only owners can complete onboarding’.
Cross-org access via GET /organizations/:id404 ‘Organization not found’ (no membership in that org).
onboarding-complete with inviteEmails where one is invalidPer-email try/catch in the loop; failed ones logged, others continue; final invitationsSent count reflects successes only.
onboarding-complete with no program existingclassTypeCreated=false returned; no error.
Welcome email send failureLogged; emailSent=false; method still returns 200.

Side effects

OperationSide effects
CreateClerk org created, FitKit org inserted, owner membership inserted.
UpdateDB write only.
Logo upload-urlRedis SET with 600s TTL.
Logo confirmDB write (logo_url), Redis DEL, R2 presigned URL cache invalidated.
Onboarding completeOptional class type insert; optional Clerk invitations + DB invitation rows; welcome email (fire-and-forget).

Permissions

Endpointowneradmincoachmember
POST /organizations(caller becomes owner)
GET /organizationsselfselfselfself (only orgs with active membership)
GET /:id
PATCH /:id
POST /:id/logo/upload-url
POST /:id/logo/confirm
POST /:id/onboarding-complete