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
| Persona | Why |
|---|---|
| Owner | Creates the org during onboarding; edits branding, contact, timezone, cancellation policy, uploads logo. |
| Admin | Edits org settings (same as owner except destructive operations). |
| Coach | Read-only on most fields. |
| Member | Reads name, logo, timezone for display. |
| Platform | Tier 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
- Create org —
POST /organizations. Also creates the matching Clerk organization (withcreatedBy=clerkIdso the caller is admin in Clerk too). Inserts an owner-rolemembershipsrow. - List my orgs —
GET /organizations— every org where the caller has anactivemembership. - Get / update — owner/admin only.
- Logo flow —
POST /:id/logo/upload-urlreturns a presigned R2 PUT URL + R2 key;POST /:id/logo/confirmvalidates that the object exists and persists the key toorganizations.logo_url. - Onboarding complete —
POST /:id/onboarding-complete(owner only). Optionally creates a class type, batch-invites members, and sends a templated welcome email. - Cancellation policy —
cancellationWindowHours+allowLateCancellationflags read by booking cancel flow. - Tier —
platformTierenum (lite|pro|elite) drives feature gates via@taikan/shared.checkTierLimitand@RequiresFeaturedecorators. - Minisite — public marketing site for the org. Owned via
minisite_contenttable (FK fromorganizations). Seeapps/api/src/organizations/minisites.controller.ts.
Relationship to other features
- users-auth — org creation calls Clerk;
clerk_organization_idstored. - memberships — org → members many-to-many through this table.
- onboarding — the wizard ends with
POST /organizationsthenPOST /:id/onboarding-complete. - scheduling-bookings —
cancellationWindowHours+allowLateCancellationread by the booking cancel path.timezoneused to format session times. - locations — direct FK from
locations.organization_id. - leads-crm —
organization_leads.organization_iddirect FK. - Platform tiers / billing —
platformTiercontrolsmaxLocations,maxMembers, feature flags (lead_management, etc.). Seeapps/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_orgcookie (apps/web/src/lib/active-org.ts), read by the protected layout /prefetchUserAndDataon the server and byActiveOrgProvider→useCurrentUseron 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.tsactiveOrgId), preloaded behind the splash and held inActiveOrgProvider;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/meconvertspending_invitationshells viaacceptPendingInvitationseven when the user already has an active membership elsewhere;createInvitationre-arms a cancelled/suspended membership row back topending_invitationon re-invite.
Current status
Shipped. Areas with active churn:
- The org-create flow creates a default program implicitly elsewhere (TODO: verify which module —
OrganizationsService.createitself only creates the org + owner membership; the wizard’shandleFinishinonboarding-content.tsxonly POSTs/organizations, no program creation visible). TheonboardingCompleteendpoint readsorganizationsand 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
logoUrlvalues that are full URLs (e.g. from Clerk imageUrl) are passed through. Relative keys starting withorgs/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).