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 Taikan org — clerk_organization_id UNIQUE.
  • Slug uniqueness — slug UNIQUE NOT NULL. Auto-generated as slugify(name) + '-' + 6char-random.
  • Caller becomes owner on create — POST /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.
  • Billing defaults: billingGraceDays=7, planRequiredToBook=false; billing_lifecycle_day null until the first lifecycle pass.

Golden paths

G1 — Owner onboarding (create org)

  1. Setup sheet, “org” section: user enters name + org type. Nothing is sent until the whole sheet is submitted. (See onboarding.)
  2. The sheet’s handleSubmit calls POST /organizations with {name, locale, currency, timezone}; type is omitted, so the column default physical applies.
  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. The sheet calls POST /:id/onboarding-complete with {deliveryModes, programNames} (no class type name, no invites); starter programs are seeded 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 }.

G5 — Edit and publish a shared brand

  1. An owner/admin opens /dashboard/branding?tab=brand. Website-only managers can still open the Website area, but cannot write the shared brand.
  2. GET /organizations/:orgId/brand-workspace returns capability and channel summaries plus the appearance editor model when the caller can manage the organization.
  3. Valid edits autosave to organization_appearance.draft with expectedDraftRevision. A conflict keeps local edits visible until the user explicitly loads the latest draft.
  4. 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.
  5. 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

RequirementWhy
FRONTEND_URL on the API is the dashboard originThe preview snapshot carries it as the renderer’s frame-ancestors and postMessage target.
MINISITE_PREVIEW_SERVER_SECRET, NEXT_PUBLIC_MINISITE_PREVIEW_ORIGINThe 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)ColumnValidationEffect
billingGraceDaysbilling_grace_daysinteger 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)
planRequiredToBookplan_required_to_bookbooleanA member with no live plan is refused booking (plan_required_to_book) instead of booking untracked
  • Passed through by OrganizationsService.update like the other typed booking settings and tracked by changedOrgSettings, so a change lands in the org settings audit diff with both sides (0 counts as a change). Returned on the organization response (organizationResponseSchema) by OrganizationsController and UsersController.
  • 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_since and the current setting on each read; nothing is re-stamped.
  • Web: Settings → Booking (dashboard/settings/(shell)/booking/page.tsx): one number input billingGraceDays (row billing-grace-days-row, range checked by the form schema with the localized graceDaysInvalid message) and the plan-required-to-book switch. The page PATCHes every field it owns.

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.
Stale shared-brand or mobile draft revision409 with the current revision/timestamp; the editor retains local work and offers an explicit reload.
Minisite manager reads brand assetsAllowed 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-integer400 (DTO validation); the settings page blocks it client-side first.

Side effects

OperationSide effects
CreateClerk org created, Taikan 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✓✗✗✗