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 — commercial standing: none | current | past_due | debt.

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.

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 limit (maxMembers) only counts status='active' rows of any role. Staff invitations bypass the tier check (see createInvitation: if (!isStaffRole(role)) { … check }).
  • 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 membersGET /organizations/:orgId/members — paginated, searchable (name/email), filterable by status/role/hasClerkAccount/profileComplete, sortable.
  2. Member detailGET /:orgId/members/:membershipId, with masked national ID.
  3. Member stats — classes-this-month, total-classes — for self (/members/me/stats) or for any member (staff-only).
  4. Update memberPATCH /: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 invitePOST /: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 memberPOST /:orgId/members/:membershipId/send-invitation — for a single imported member.
  8. Accept pending invitationsPOST /invitations/accept-pending (auth required, throttled 10/min). Idempotent. Also runs from the Clerk webhook on user.created.
  9. Membership activation eventMEMBERSHIP_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-authmemberships.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-crmOrganizationLeadsService.convertLead inserts a memberships row with sourceLeadId set and emits MEMBERSHIP_ACTIVATED { source: 'lead_converted' }.
  • scheduling-bookingsbookings.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 shell users can build up if invitations are never accepted; no scheduled cleanup observed.
  • expiresAt on invitations is checked lazily during acceptPendingInvitations; expired ones get their status flipped to expired only when the recipient tries to accept.