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

Memberships

What is this

A memberships row links a user to an organization with three independent state axes:

  1. role — what the user can do: owner | admin | coach | member.
  2. status — where they are in the lifecycle: active | invited | pending_invitation | suspended | cancelled.
  3. payment_status — none | current | past_due | debt. A denormalised mirror written by the billing layer, never read for decisions: it is write-mostly and drifts. “Is this member paying” is answered by the resolved memberStatus (apps/api/src/memberships/member-status.ts) and the ?paymentStatus= roster filter, both computed from live subscriptions rows — see behavior.md › Payment status.

A user can have many memberships (one per org). This is the table every authorization check pivots through: MembershipsService.requireMembership(orgId, clerkId) is the entry point for every org-scoped API.

The module also owns the invitation lifecycle: Clerk-side invitation + DB-side invitations row + a pending_invitation shell membership, all created together so the org sees the prospective member immediately. Auto-acceptance happens on the user’s first sign-in (webhook) or as a fallback on GET /users/me.

Member responses also derive an independent account status from the user’s Clerk link and the organization’s pending invitations: activated | invited | not_invited. It answers whether the person can sign in or still needs an invitation; it does not alter authorization, roster membership, or training access.

Who uses it

PersonaWhy
OwnerSole authority for owner-role assignments; can invite anyone; can suspend/cancel/role-change any non-owner.
AdminInvites/manages members and other admins (not owners); cannot invite an owner.
CoachRead-only on the member list (TODO: verify — listMembers requires only requireMembership, not a staff check).
MemberReads own membership stats; cannot list other members.

Persona impact

  • Staff roles (owner|admin|coach via isStaffRole) bypass booking credit deduction and quota checks. Coaches book classes for free against their own session.
  • Suspending / cancelling the last owner is blocked. Reassigning the last owner role to non-owner is blocked. Both checks live in updateMembership.
  • Tier limits count distinct audiences: active role='member' rows consume maxMembers; active role='coach' rows consume maxCoaches; owners and admins consume neither. Pending invitations do not reserve seats.
  • pending_invitation is a real membership row, not a placeholder — it shows up in getUserMemberships so the new user sees the org immediately after sign-in.

High-level capabilities

  1. List members — GET /organizations/:orgId/members — paginated (page/limit, max 100), searchable (name/email — every term must match a column, so full names work), filterable by status/role/billing/account status/profile completion, sortable. Rows carry the resolved accountStatus; ?accountStatus=not_invited finds people with no Clerk account and no pending invitation.
  2. Member detail — GET /:orgId/members/:membershipId, with masked national ID and accountStatus.
  3. Member stats — classes-this-month, total-classes — for self (/members/me/stats) or for any member (staff-only).
  4. Update member — PATCH /:orgId/members/:membershipId — change role/status/profile.
  5. Invitations — list pending, create (single), resend, revoke. Each create both creates the Clerk invitation (sends email) and the DB row + shell membership.
  6. Bulk invite — POST /:orgId/members/bulk-invite with an array of membershipIds (imported users without Clerk accounts). Skips already-invited or already-linked users. Reports sent / skipped / failed.
  7. Send invitation to existing member — POST /:orgId/members/:membershipId/send-invitation — for a single imported member.
  8. Accept pending invitations — POST /invitations/accept-pending (auth required, throttled 10/min). Idempotent. Also runs from the Clerk webhook on user.created.
  9. Membership activation event — MEMBERSHIP_ACTIVATED emitted on every status flip to active, with payload { organizationId, userId, membershipId, role, source: 'invitation_accepted'|'lead_converted' }. Consumed by forms fan-out, analytics, etc.

Relationship to other features

  • users-auth — memberships.user_id FKs into users. Webhook auto-accepts invitations.
  • organizations — org creation inserts the first owner membership; tier limits checked on invite.
  • onboarding — invite step uses bulk-invite or per-email createInvitation.
  • leads-crm — OrganizationLeadsService.convertLead inserts a memberships row with sourceLeadId set and emits MEMBERSHIP_ACTIVATED { source: 'lead_converted' }.
  • scheduling-bookings — bookings.membership_id and class_sessions.coach_membership_id both point here; staff bypass enforced via isStaffRole(membership.role).

Current status

Shipped. Notable bits:

  • requireMembership has a per-process 30s cache (MEMBERSHIP_CACHE_TTL_MS). invalidateMembershipCache is exposed for callers that just changed a membership.
  • The invitation flow creates a shell user for never-seen-before emails (INSERT INTO users (email) ON CONFLICT DO NOTHING) so the membership row is valid before sign-up.
  • Bulk-invite skip reasons are exact machine strings: already_has_account, already_invited.

Known gaps

  • listMembers requires only membership (any role) — including non-staff members. (TODO: verify whether members should be blocked from listing other members; appears intentional for community-feed purposes.)
  • pending_invitation shells from staff invitations can build up if invitations are never accepted; no scheduled cleanup. Join-link shells are covered: the hourly join-shell sweep (runSweepAbandonedJoinShells) discards them after 24h, and a corrected re-submission discards the typo’d one at once (see subscriptions-plans/behavior.md).
  • expiresAt on invitations is checked lazily during acceptPendingInvitations; expired ones get their status flipped to expired only when the recipient tries to accept.