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

Organizations

What is this

An organization is Taikan’s tenant unit — a single studio, gym, coach business, or chain. Every domain row (sessions, bookings, members, leads, plans, locations, programs, workouts, payments) is tied back to exactly one organizations.id. The org row holds branding (logoUrl, name, slug, description), localization (timezone, currency), commercial state (platformTier), and a few cross-cutting policies (cancellationWindowHours, allowLateCancellation).

Organizations are also mirrored into Clerk as Clerk organizations (clerk_organization_id) — Taikan creates one in Clerk on POST /organizations so Clerk’s org-membership UI (org switcher, invitations) is wired up alongside Taikan’s memberships rows.

Who uses it

PersonaWhy
OwnerCreates the org during onboarding; edits branding, contact, timezone, cancellation policy, uploads logo.
AdminEdits org settings (same as owner except destructive operations).
CoachRead-only on most fields.
MemberReads name, logo, timezone for display.
PlatformTier transitions (platform_tier) driven by platform-billing module.

Persona impact

  • Owner-only: completeOnboarding (only the owner can finalize the org setup flow). Suspending or cancelling the last owner membership is blocked.
  • Logo uploads are two-step: presigned R2 upload URL + confirm. Lite tier has a max upload size of 2 MB and limited MIME types (image/jpeg, image/png, image/webp). 10-minute upload window.
  • Slug auto-generated on create from name + 6-char random suffix; unique. Currently no slug-edit endpoint.

High-level capabilities

  1. Create orgPOST /organizations. Also creates the matching Clerk organization (with createdBy=clerkId so the caller is admin in Clerk too). Inserts an owner-role memberships row.
  2. List my orgsGET /organizations — every org where the caller has an active membership.
  3. Get / update — owner/admin only.
  4. Logo flowPOST /:id/logo/upload-url returns a presigned R2 PUT URL + R2 key; POST /:id/logo/confirm validates that the object exists and persists the key to organizations.logo_url.
  5. Onboarding completePOST /:id/onboarding-complete (owner only). Optionally creates a class type, batch-invites members, and sends a templated welcome email.
  6. Cancellation policycancellationWindowHours + allowLateCancellation flags read by booking cancel flow.
  7. TierplatformTier enum (lite|pro|elite) drives feature gates via @taikan/shared.checkTierLimit and @RequiresFeature decorators.
  8. Minisite — public marketing site for the org. Owned via minisite_content table (FK from organizations). See apps/api/src/organizations/minisites.controller.ts.

Relationship to other features

  • users-auth — org creation calls Clerk; clerk_organization_id stored.
  • memberships — org → members many-to-many through this table.
  • onboarding — the wizard ends with POST /organizations then POST /:id/onboarding-complete.
  • scheduling-bookingscancellationWindowHours + allowLateCancellation read by the booking cancel path. timezone used to format session times.
  • locations — direct FK from locations.organization_id.
  • leads-crmorganization_leads.organization_id direct FK.
  • Platform tiers / billingplatformTier controls maxLocations, maxMembers, feature flags (lead_management, etc.). See apps/api/src/platform-tiers/.

Multi-org membership & the active-org switcher

A Clerk user can hold memberships in any number of orgs (the memberships unique constraint is (user_id, organization_id)). The API never resolves a “current org” server-side — org identity is always an explicit :orgId path param validated by requireMembership, so any org the caller belongs to is reachable per-request. The active org is purely a client concept:

  • Selection precedence (identical on web and mobile): the explicitly selected org if it still matches an active membership, else the highest-privilege active membership (owner > admin > coach > member).
  • Web: selection persisted in the taikan_active_org cookie (apps/web/src/lib/active-org.ts), read by the protected layout / prefetchUserAndData on the server and by ActiveOrgProvideruseCurrentUser on the client. Switcher UI: OrgSwitcher (components/shared/org-switcher.tsx) wraps the org block in the dashboard sidebar and the member header; it renders only when the user has 2+ active memberships. Switching sets the cookie and hard-navigates to the target role’s home, which resets query cache, realtime, and analytics grouping.
  • Mobile: selection persisted in AsyncStorage (src/lib/settings-store.ts activeOrgId), preloaded behind the splash and held in ActiveOrgProvider; OrgSwitcher (src/components/fk/org-switcher.tsx) makes the member-header org block tappable. Switching invalidates the whole React Query cache (all org-scoped keys embed the orgId). Cleared on sign-out.
  • Second-org invitations: GET /users/me converts pending_invitation shells via acceptPendingInvitations even when the user already has an active membership elsewhere; createInvitation re-arms a cancelled/suspended membership row back to pending_invitation on re-invite.

Current status

Shipped. Areas with active churn:

  • The org-create flow creates a default program implicitly elsewhere (TODO: verify which module — OrganizationsService.create itself only creates the org + owner membership; the wizard’s handleFinish in onboarding-content.tsx only POSTs /organizations, no program creation visible). The onboardingComplete endpoint reads organizations and tries to find an existing program when creating a class type — implying programs are created elsewhere, possibly via a default-seeding step or a separate API call from the web app. (TODO: verify.)
  • Logo upload is R2-only; legacy logoUrl values that are full URLs (e.g. from Clerk imageUrl) are passed through. Relative keys starting with orgs/ get presigned for read.

Known gaps

  • No “transfer ownership” endpoint. Updating an owner’s role to non-owner is guarded against if they’re the last owner.
  • No org soft-delete path exposed in the controller. The schema has deleted_at, the service does not write it.
  • Slug is auto-generated; no edit. If a name collision happens after slugification, the 6-char suffix makes it unique but the URL changes if the org is recreated.
  • Currency is a plain varchar(3) — no validation against ISO 4217 codes at the API layer (TODO: verify whether Zod schema enforces this).