Memberships — Behavior
State machines
Membership status (memberships.status, enum membership_status)
| From | To | Trigger |
|---|---|---|
| (insert) | active | Owner self-create on org-create; convertLead; acceptPendingInvitations for first-time / re-accept paths. |
| (insert) | pending_invitation | createInvitation for known or new email — shell membership inserted alongside the invitation row. |
pending_invitation | active | acceptPendingInvitations (webhook on user.created, or fallback in GET /users/me). |
pending_invitation | cancelled | revokeInvitation. |
active | suspended | updateMembership (owner protected). |
active | cancelled | updateMembership; usersService.deleteSelf cascade. |
| any | active | Re-accept of a fresh invitation to a previously-cancelled user (existing membership flipped back to active). |
cancelled | cancelled + deletedAt | MemberErasureService.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:
| State | Meaning |
|---|---|
debt | Outstanding balance (subscription debt, or any live row with debt_amount_in_cents > 0) |
past_due | A charge failed and is being retried |
paying | An activated, current subscription |
presale | Sold, card on file, first charge on opening day (scheduled, FIT-287) |
paused | Deliberately on hold |
checkout_pending | Checkout started, no money has arrived |
withdrawn | Backed out of a presale sale; nothing live now — presale churn |
churned | Previously activated, everything since ended — ordinary churn |
none | Never 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. withdrawnandchurnedare 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
sendInviteis 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.
invitationSentin 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 safety —
updateMembershipblocks demoting the lastowner+activerow, and blocks setting it tosuspendedorcancelled. - Tier-aware invite — non-staff invites count against
maxMembers; staff invites bypass. - Activation event invariant —
MEMBERSHIP_ACTIVATEDis emitted exactly once per transition to active. (Consumers must be idempotent — webhook retries are possible.) - Cache invalidation —
requireMembershipcache has 30s TTL. Direct membership writes don’t auto-invalidate; callers that mutate must callinvalidateMembershipCache(orgId, userId)(TODO: verify whetherupdateMembershipactually invokes it — appears not to in the read code). - Invitation expiry —
INVITE_EXPIRY_DAYS = 7. Checked only at accept time. - Search across users + memberships —
listMembersfilters byilikeagainstusers.first_name,users.last_name,users.email. - Soft-deleted memberships are invisible —
listMembersandgetMemberDetailfilterdeletedAt 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/permanent → MemberErasureService.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:
| Class | Treatment | Tables |
|---|---|---|
| Personal content | Hard-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 / legal | Keep row, scrub person | payment 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-operational | Untouched | coach-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.
Member list search
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
POST /:orgId/invitationswith{email, role:'member'}.- Service:
requireMembershipcheck (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
invitationsrow. - INSERT
membershipsshell withstatus='pending_invitation'(creates shell user if needed).
- Returns the invitation row.
G2 — New user accepts invitation
- User clicks email, signs up at Clerk.
- Webhook
user.createdlands at the API. findOrCreateFromClerkeither creates or links the user row.acceptPendingInvitations(clerkId):- Find all
invitationsmatching the user’s email (case-insensitive) withstatus='pending'. - For each: if expired, mark
expiredand continue. Otherwise transaction: if a membership row exists (pending_invitation), flip it toactive. Else insert a freshmembershipsrow. Mark invitationacceptedwithaccepted_at. - Emit
MEMBERSHIP_ACTIVATEDfor each new activation.
- Find all
G3 — Bulk invite imported members
- Owner has 50 imported members with
clerk_id=NULL. POST/:orgId/members/bulk-invitewith theirmembershipIds. - Service fetches each (membership_id, user_id, role, email, clerk_id), and the org’s pending invitations.
- For each: skip if
clerk_idexists or pending invite exists (with reason strings). Otherwise call Clerk + INSERTinvitations. - Returns
{sent, skipped, failed, summary:{total, sent, skipped, failed}}.
G4 — Member self-stats
GET /:orgId/members/me/stats.- Service computes
classesThisMonthandtotalClassesby joiningbookings→class_sessions→class_types→programswithprograms.organization_id=orgIdandbookings.membership_id=callerMembershipId.
G5 — Update role/status/profile
- Owner PATCH
/:orgId/members/:idwith{role: 'coach'}. - Service:
requireMembership(owner/admin); load target; if target is owner, run owner-protection guards. - Optional: if
updates.profilepresent, callusersService.updateProfile(target.userId, updates.profile). - UPDATE the membership row.
Edge cases & error states
| Scenario | Behavior |
|---|---|
| Invite an existing active member | 400 ‘User is already a member or has a pending membership’. |
Invite an email with a pending_invitation already | 400 ‘A pending invitation already exists for this email’. |
| Resend a non-pending invitation | 400 ‘Invitation is no longer pending’. |
| Revoke a non-pending invitation | 400 ‘Invitation is no longer pending’. |
| Member tries to invite | 403 ‘Only owners and admins can invite members’. |
| Admin tries to invite an owner | 403 ‘Only owners can invite owners’. |
| Demote last owner | 403 ‘Cannot change the role of the last owner’. |
| Suspend/cancel the owner | 403 ‘Cannot suspend or cancel the owner’. |
Send-invitation to a member who has clerk_id already | 400 ‘Member already has a Clerk account’. |
| Tier exceeded (member invite) | 403 ‘Member limit reached (N/M). Upgrade your plan to add more.’. |
| Accept expired invitation | Silently flips invitation to expired; no membership change; user not added. |
| Webhook auto-accept races with manual accept-pending POST | Both transactions tolerate each other — the existingMembership branch handles already-active rows by skipping. |
Cross-org GET /:orgId/members/:id | 404 ‘Member not found’. |
requireMembership for non-active membership | 403 ‘Not a member of this organization’ (status=‘active’ filter in the SELECT). |
Side effects
| Operation | Side effects |
|---|---|
| Create invitation | Clerk invitation email; DB invitation row; shell user (if email new) + shell membership. |
| Revoke invitation | Best-effort Clerk revoke; status='revoked'; cancel the pending_invitation membership for that email. |
| Resend invitation | Best-effort Clerk revoke of old token; new Clerk invitation; clerk_invitation_id + expires_at updated. |
| Accept pending | Memberships 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 profile | Profile mutates users row (org-wide impact). |
Permissions
| Endpoint | owner | admin | coach | member |
|---|---|---|---|---|
GET /members | ✓ | ✓ | ✓ | ✓ (returns all) |
GET /members/me/stats | self | self | self | self |
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) | self | self | self | self |
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.