Organizations — Behavior
State
No explicit status enum. Lifecycle is conveyed by:
| Field | Semantic |
|---|---|
is_active=true | Default; org operates normally. |
is_active=false | Inactive. (No code path observed that flips this off — TODO: verify.) |
deleted_at IS NOT NULL | Soft-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 org —
clerk_organization_idUNIQUE. - Slug uniqueness —
slugUNIQUE NOT NULL. Auto-generated asslugify(name) + '-' + 6char-random. - Caller becomes owner on create —
POST /organizationsalways inserts amembershipsrow withrole='owner', status='active'. - Owner protection on membership update — flipping the last owner away from
owneror tosuspended/cancelledis rejected byMembershipsService.updateMembership. (No similar guard onDELETE /users/meself-delete; see Known Gaps in README.) - Onboarding-complete is owner-only — even admins are rejected.
- Logo upload size 2MB max, MIME ∈
{jpeg, png, webp}. Ther2Keyis fixed toorgs/${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)
- Wizard step “org”: user enters name + org type. (See onboarding.)
- Wizard
handleFinishcallsPOST /organizationswith{name, type}. - 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
organizationsrow with default tierlite. - Inserts owner
membershipsrow.
- Calls
- Legal consents POST (
/legal/consents) immediately follows. - Wizard calls
POST /:id/onboarding-completewith empty body (default — no class type, no invites) and the welcome email is sent.
G2 — Update branding
PATCH /organizations/:idwith{name, description, contactEmail, contactPhone, website, logoUrl}.- All fields conditional on
!== undefined; empty string forcontactEmail/Phone/website/logoUrlis normalized tonull.
G3 — Logo upload
- Web: user picks file.
POST /:id/logo/upload-urlwith{mimeType, fileSize}. - Server validates MIME + size; generates presigned R2 PUT URL; stashes
{r2Key, mimeType}in Redis underupload:logo:${orgId}with TTL 600s. - Web: PUTs the file directly to R2 with the presigned URL.
- Web:
POST /:id/logo/confirm. Server verifies the Redis blob andr2.objectExists; UPDATEslogo_url = orgs/${orgId}/logo; invalidates cached presigned read URL; deletes the Redis blob.
G4 — Complete onboarding
POST /organizations/:id/onboarding-completewith optional{classTypeName, defaultDurationMin, defaultCapacity, inviteEmails}.- If
classTypeName: find the org’s first program; insert a class type under it. - If
inviteEmails: loop and callMembershipsService.createInvitationfor each. - Send welcome email via the
welcometemplate (apps/api/src/notifications/templates/welcome.ts). Pulls owner name from Clerk. - Returns
{ classTypeCreated, invitationsSent, emailSent }.
Edge cases & error states
| Scenario | Behavior |
|---|---|
POST /organizations with name that slugifies to existing slug | Random 6-char suffix prevents collision in practice; theoretical race could fail with a UNIQUE violation (not handled). |
Clerk createOrganization fails | The DB org row is never created (Clerk call happens first); error bubbles. |
| Logo upload-url with disallowed MIME | 400 ‘Unsupported file type. Use JPEG, PNG, or WebP.’. |
| Logo upload-url with size > 2MB | 400 ‘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 R2 | 400 ‘Upload not found. Please re-upload the file.’. |
| Coach/member tries to update org | 403 ‘Only owners and admins can update the organization’. |
Non-owner calls onboarding-complete | 403 ‘Only owners can complete onboarding’. |
Cross-org access via GET /organizations/:id | 404 ‘Organization not found’ (no membership in that org). |
onboarding-complete with inviteEmails where one is invalid | Per-email try/catch in the loop; failed ones logged, others continue; final invitationsSent count reflects successes only. |
onboarding-complete with no program existing | classTypeCreated=false returned; no error. |
| Welcome email send failure | Logged; emailSent=false; method still returns 200. |
Side effects
| Operation | Side effects |
|---|---|
| Create | Clerk org created, FitKit org inserted, owner membership inserted. |
| Update | DB write only. |
| Logo upload-url | Redis SET with 600s TTL. |
| Logo confirm | DB write (logo_url), Redis DEL, R2 presigned URL cache invalidated. |
| Onboarding complete | Optional class type insert; optional Clerk invitations + DB invitation rows; welcome email (fire-and-forget). |
Permissions
| Endpoint | owner | admin | coach | member |
|---|---|---|---|---|
POST /organizations | (caller becomes owner) | |||
GET /organizations | self | self | self | self (only orgs with active membership) |
GET /:id | ✓ | ✓ | ✓ | ✓ |
PATCH /:id | ✓ | ✓ | ✗ | ✗ |
POST /:id/logo/upload-url | ✓ | ✓ | ✗ | ✗ |
POST /:id/logo/confirm | ✓ | ✓ | ✗ | ✗ |
POST /:id/onboarding-complete | ✓ | ✗ | ✗ | ✗ |