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

Memberships — Behavior

State machines

Membership status (memberships.status, enum membership_status)

FromToTrigger
(insert)activeOwner self-create on org-create; convertLead; acceptPendingInvitations for first-time / re-accept paths.
(insert)pending_invitationcreateInvitation for known or new email — shell membership inserted alongside the invitation row.
pending_invitationactiveacceptPendingInvitations (webhook on user.created, or fallback in GET /users/me).
pending_invitationcancelledrevokeInvitation.
activesuspendedupdateMembership (owner protected).
activecancelledupdateMembership; usersService.deleteSelf cascade.
anyactiveRe-accept of a fresh invitation to a previously-cancelled user (existing membership flipped back to active).
cancelledcancelled + deletedAtMemberErasureService.eraseMember — permanent erasure (see below). Terminal.
invited(existing enum, unused in service paths visible here)TODO: verify whether invited is ever written.

MEMBERSHIP_ACTIVATED event fires whenever a row transitions into active from any other state (including the create-with-active path).

Membership role (membership_role)

owner > admin > coach > member. Service-level rules (in createInvitation / updateMembership):

  • Only owners can invite or promote-to owners.
  • Owners and admins can invite or promote-to admins.
  • Coaches and members cannot invite anyone.
  • Cannot demote the last owner to a non-owner role (single-owner protection).

Payment status (membership_payment_status)

none | current | past_due | debt. Set by the payments / subscriptions layer, not this module. Read here only on listMember responses.

This mirror is write-mostly and drifts — nothing gates on it. Do not read it to answer “is this member paying”; use the derived billing state below, computed from live subscription rows.

Derived billing state (billing on the member list)

memberships.status = 'active' says a person is on the roster; it says nothing about money, which is what an owner actually wants from the members screen. GET /organizations/:orgId/members therefore carries a derived billing: { state, planName } per member, computed in MembershipsService.loadMemberBilling (contract: memberBillingSchema in @taikan/shared).

One state per member — the most billing-relevant subscription wins, in this order:

StateMeaning
debtOutstanding balance (subscription debt, or any live row with debt_amount_in_cents > 0)
past_dueA charge failed and is being retried
payingAn activated, current subscription
presaleSold, card on file, first charge on opening day (scheduled, FIT-287)
pausedDeliberately on hold
checkout_pendingCheckout started, no money has arrived
withdrawnBacked out of a presale sale; nothing live now — presale churn
churnedPreviously activated, everything since ended — ordinary churn
noneNever held anything live

Two distinctions worth keeping straight:

  • An abandoned checkout is none, not churn. Walking away from a payment page was never a membership; reporting it as churn would fill the owner’s churn list with closed payment pages.
  • withdrawn and churned are separate on purpose. Mid-presale, the owner needs to see who backed out of a sale distinctly from who ended a membership that actually ran.

The list filters on the same derivation: ?paymentStatus=at_risk | paying | not_paying | withdrawn | churned, all matched against subscriptions via EXISTS / NOT EXISTS, never the mirror. not_paying treats a bare pending checkout as not paying — no money has arrived — which is exactly the population an owner chases once a join link goes public.

Desk registration (POST /organizations/:orgId/members)

MembershipsService.createMember registers a member at the counter with status: 'active' immediately, no Clerk account required.

It exists because the invite path cannot finish the job: an invited member sits at pending_invitation until they sign in, and both POST /members/:id/register-card and POST /members/:id/enroll refuse that status — so an owner could not take a card or assign a plan for someone standing in front of them. A person at the desk is a member.

  • Email is optional (a walk-in who never uses the app is still a member); it is required only when sendInvite is true.
  • The Clerk invitation is best-effort and never fails the registration — the membership is what the desk needs, and a mail provider hiccup must not cost it. invitationSent in the response says what happened.
  • Same tier seat cap and role grant matrix as the invite path; an existing cancelled/suspended membership is reactivated rather than duplicated.
  • Emits MEMBERSHIP_ACTIVATED (membershipSource: 'manual'), so welcome fan-out and the CRM’s won-lead conversion behave as they do for any other activation.

The dashboard drives this through RegisterMemberWizard (details → card → plan), which opens the gateway card page in a new tab and polls for the token rather than redirecting away and losing the half-finished registration.

Invariants

  • UNIQUE(user_id, organization_id) — at most one membership per (user, org), regardless of status.
  • Last-owner safetyupdateMembership blocks demoting the last owner+active row, and blocks setting it to suspended or cancelled.
  • Tier-aware invite — non-staff invites count against maxMembers; staff invites bypass.
  • Activation event invariantMEMBERSHIP_ACTIVATED is emitted exactly once per transition to active. (Consumers must be idempotent — webhook retries are possible.)
  • Cache invalidationrequireMembership cache has 30s TTL. Direct membership writes don’t auto-invalidate; callers that mutate must call invalidateMembershipCache(orgId, userId) (TODO: verify whether updateMembership actually invokes it — appears not to in the read code).
  • Invitation expiryINVITE_EXPIRY_DAYS = 7. Checked only at accept time.
  • Search across users + membershipslistMembers filters by ilike against users.first_name, users.last_name, users.email.
  • Soft-deleted memberships are invisiblelistMembers and getMemberDetail filter deletedAt IS NULL, so both self-serve account deletion and owner-initiated erasure remove the member from the org’s view.

Permanent erasure (right-to-be-forgotten)

DELETE /organizations/:orgId/members/:membershipId/permanentMemberErasureService.eraseMember. Owner-only, target must not be an owner, and the membership must already be cancelled (deliberate two-step: cancel, then erase). The web danger zone shows the action only for cancelled members, with a type-the-name confirmation.

Org-scoped by design. Users are platform-level; the owner destroys the member’s data within their org only. When this org is the user’s entire footprint (no other live memberships, no active course entitlements sold by other orgs) the erasure escalates automatically: the users row is scrubbed in place (email becomes a deleted+{id}@deleted.taikan.fit tombstone, all PII columns nulled), device tokens are soft-deleted, notification prefs and agent conversations dropped, and the Clerk identity is deleted.

Data classes:

ClassTreatmentTables
Personal contentHard-delete (+R2 objects)progress photos, body metrics + settings, goals, workout results/PRs/assignments, feed items + comments + reactions, bookings, program enrollments, DM messages + attachments, inbox conversations, announcement reads, automation enrollments, member profile, pending invitations
Financial / legalKeep row, scrub personpayment transactions + subscriptions (point at the soft-deleted membership), card tokens blanked in place, course entitlements revoked not deleted, signed forms + legal consents retained as legal artifacts, source lead contact fields nulled
Org-operationalUntouchedcoach-authored content, schedules, task rows (structured linkedUserId cleared)

Every erasure writes an audit_logs row (member.erased, actor, org, escalation flag, R2 object count) and a member_erased tracking event. R2 deletions run best-effort after the DB transaction commits; failures are logged for manual reconcile.

Known v1 gaps (accepted): exercise-comment bodies authored by the member survive (author is scrubbed on escalation); free-text task descriptions mentioning the member are not rewritten; the R2 form-upload binaries under {orgId}/forms/... are retained with the signed PDFs by design.

Search (?search=) is term-based, not one substring over the whole box. The value is split on whitespace/commas (max 6 terms, LIKE wildcards escaped) and every term must ILIKE one of users.first_name, users.last_name, users.email. So “Jane Doe” and “Doe, Jane” both find Jane Doe — a single %Jane Doe% pattern matches no column on its own and used to return nothing. Shared helper: apps/api/src/common/search-terms.ts.

Pagination is ?page= (1-based) + ?limit= (default 20, max MEMBERS_LIST_MAX_LIMIT = 100); the response carries { data, total, page, limit }. The web table fixes the page size at 20 and shows its prev/next footer only once there is a second page. Changing a filter or the search resets to page 1.

Golden paths

G1 — Owner invites a single member

  1. POST /:orgId/invitations with {email, role:'member'}.
  2. Service:
    • requireMembership check (owner/admin).
    • Role gating (cannot invite owner unless caller is owner).
    • Tier check (skip for staff roles).
    • Reject if existing membership in active/pending/invited state.
    • Reject if pending invitation already exists for this email in this org.
    • Call Clerk invitations.createInvitation (sends email).
    • INSERT invitations row.
    • INSERT memberships shell with status='pending_invitation' (creates shell user if needed).
  3. Returns the invitation row.

G2 — New user accepts invitation

  1. User clicks email, signs up at Clerk.
  2. Webhook user.created lands at the API.
  3. findOrCreateFromClerk either creates or links the user row.
  4. acceptPendingInvitations(clerkId):
    • Find all invitations matching the user’s email (case-insensitive) with status='pending'.
    • For each: if expired, mark expired and continue. Otherwise transaction: if a membership row exists (pending_invitation), flip it to active. Else insert a fresh memberships row. Mark invitation accepted with accepted_at.
    • Emit MEMBERSHIP_ACTIVATED for each new activation.

G3 — Bulk invite imported members

  1. Owner has 50 imported members with clerk_id=NULL. POST /:orgId/members/bulk-invite with their membershipIds.
  2. Service fetches each (membership_id, user_id, role, email, clerk_id), and the org’s pending invitations.
  3. For each: skip if clerk_id exists or pending invite exists (with reason strings). Otherwise call Clerk + INSERT invitations.
  4. Returns {sent, skipped, failed, summary:{total, sent, skipped, failed}}.

G4 — Member self-stats

  1. GET /:orgId/members/me/stats.
  2. Service computes classesThisMonth and totalClasses by joining bookingsclass_sessionsclass_typesprograms with programs.organization_id=orgId and bookings.membership_id=callerMembershipId.

G5 — Update role/status/profile

  1. Owner PATCH /:orgId/members/:id with {role: 'coach'}.
  2. Service: requireMembership (owner/admin); load target; if target is owner, run owner-protection guards.
  3. Optional: if updates.profile present, call usersService.updateProfile(target.userId, updates.profile).
  4. UPDATE the membership row.

Edge cases & error states

ScenarioBehavior
Invite an existing active member400 ‘User is already a member or has a pending membership’.
Invite an email with a pending_invitation already400 ‘A pending invitation already exists for this email’.
Resend a non-pending invitation400 ‘Invitation is no longer pending’.
Revoke a non-pending invitation400 ‘Invitation is no longer pending’.
Member tries to invite403 ‘Only owners and admins can invite members’.
Admin tries to invite an owner403 ‘Only owners can invite owners’.
Demote last owner403 ‘Cannot change the role of the last owner’.
Suspend/cancel the owner403 ‘Cannot suspend or cancel the owner’.
Send-invitation to a member who has clerk_id already400 ‘Member already has a Clerk account’.
Tier exceeded (member invite)403 ‘Member limit reached (N/M). Upgrade your plan to add more.’.
Accept expired invitationSilently flips invitation to expired; no membership change; user not added.
Webhook auto-accept races with manual accept-pending POSTBoth transactions tolerate each other — the existingMembership branch handles already-active rows by skipping.
Cross-org GET /:orgId/members/:id404 ‘Member not found’.
requireMembership for non-active membership403 ‘Not a member of this organization’ (status=‘active’ filter in the SELECT).

Side effects

OperationSide effects
Create invitationClerk invitation email; DB invitation row; shell user (if email new) + shell membership.
Revoke invitationBest-effort Clerk revoke; status='revoked'; cancel the pending_invitation membership for that email.
Resend invitationBest-effort Clerk revoke of old token; new Clerk invitation; clerk_invitation_id + expires_at updated.
Accept pendingMemberships activated; invitations marked accepted; MEMBERSHIP_ACTIVATED event per activation.
Convert lead (from leads-crm)New membership inserted with source_lead_id set; MEMBERSHIP_ACTIVATED { source:'lead_converted' }.
Update membership profileProfile mutates users row (org-wide impact).

Permissions

Endpointowneradmincoachmember
GET /members✓ (returns all)
GET /members/me/statsselfselfselfself
GET /members/:id
GET /members/:id/stats
PATCH /members/:id
GET /invitations
POST /invitations
POST /invitations/:id/resend
DELETE /invitations/:id
POST /members/bulk-invite
POST /members/:id/send-invitation
POST /invitations/accept-pending (bearer-auth)selfselfselfself

Members can list and get-by-id other members in the same org (e.g. for community feed UX). They cannot read or write profile/stats of others, and they cannot invite.