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 Taikan 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. - Billing defaults:
billingGraceDays=7,planRequiredToBook=false;billing_lifecycle_daynull until the first lifecycle pass.
Golden paths
G1 — Owner onboarding (create org)
- Setup sheet, “org” section: user enters name + org type. Nothing is sent until the whole sheet is submitted. (See onboarding.)
- The sheet’s
handleSubmitcallsPOST /organizationswith{name, locale, currency, timezone};typeis omitted, so the column defaultphysicalapplies. - 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. - The sheet calls
POST /:id/onboarding-completewith{deliveryModes, programNames}(no class type name, no invites); starter programs are seeded 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 }.
G5 — Edit and publish a shared brand
- An owner/admin opens
/dashboard/branding?tab=brand. Website-only managers can still open the Website area, but cannot write the shared brand. GET /organizations/:orgId/brand-workspacereturns capability and channel summaries plus the appearance editor model when the caller can manage the organization.- Valid edits autosave to
organization_appearance.draftwithexpectedDraftRevision. A conflict keeps local edits visible until the user explicitly loads the latest draft. - Shared source images are immutable WebP objects under
orgs/:orgId/brand-library/:uuid/:widthxheight.webp. Palette rows retain UUID references; read models resolve them to API-relative public asset routes. - Publication snapshots only the reviewed shared revision. It updates an already-published mobile palette and invalidates active app release requests, but never promotes a mobile draft, website draft, site publication, or app release.
Environment prerequisites
| Requirement | Why |
|---|---|
FRONTEND_URL on the API is the dashboard origin | The preview snapshot carries it as the renderer’s frame-ancestors and postMessage target. |
MINISITE_PREVIEW_SERVER_SECRET, NEXT_PUBLIC_MINISITE_PREVIEW_ORIGIN | The ticket transport between dashboard, API and renderer. |
Billing grace setting
Two typed org columns drive unpaid payment handling and the booking billing gate:
Field (PATCH /organizations/:id) | Column | Validation | Effect |
|---|---|---|---|
billingGraceDays | billing_grace_days | integer 0–30 (MAX_BILLING_GRACE_DAYS) | “Days to pay before the plan is cancelled”: org calendar days a plan stays in debt (bookable) before the lifecycle cancels it for non-payment; also sets the card retry days (halfway and the day before) |
planRequiredToBook | plan_required_to_book | boolean | A member with no live plan is refused booking (plan_required_to_book) instead of booking untracked |
- Passed through by
OrganizationsService.updatelike the other typed booking settings and tracked bychangedOrgSettings, so a change lands in the org settings audit diff with both sides (0 counts as a change). Returned on the organization response (organizationResponseSchema) byOrganizationsControllerandUsersController. billing_lifecycle_day(the org-local day the lifecycle pass last completed) is neither settable nor returned.- A change applies to every open debt at once: cancellation and retry instants are computed from
debt_sinceand the current setting on each read; nothing is re-stamped. - Web: Settings → Booking (
dashboard/settings/(shell)/booking/page.tsx): one number inputbillingGraceDays(rowbilling-grace-days-row, range checked by the form schema with the localizedgraceDaysInvalidmessage) and theplan-required-to-bookswitch. The page PATCHes every field it owns.
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. |
| Stale shared-brand or mobile draft revision | 409 with the current revision/timestamp; the editor retains local work and offers an explicit reload. |
| Minisite manager reads brand assets | Allowed so the Website area remains useful without organization-management permission. Native variant generation still requires organization management. |
PATCH a grace length outside 0–90 or non-integer | 400 (DTO validation); the settings page blocks it client-side first. |
Side effects
| Operation | Side effects |
|---|---|
| Create | Clerk org created, Taikan 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 | ✓ | ✗ | ✗ | ✗ |