Memberships
What is this
A memberships row links a user to an organization with three independent state axes:
role— what the user can do:owner | admin | coach | member.status— where they are in the lifecycle:active | invited | pending_invitation | suspended | cancelled.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 resolvedmemberStatus(apps/api/src/memberships/member-status.ts) and the?paymentStatus=roster filter, both computed from livesubscriptionsrows — seebehavior.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
| Persona | Why |
|---|---|
| Owner | Sole authority for owner-role assignments; can invite anyone; can suspend/cancel/role-change any non-owner. |
| Admin | Invites/manages members and other admins (not owners); cannot invite an owner. |
| Coach | Read-only on the member list (TODO: verify — listMembers requires only requireMembership, not a staff check). |
| Member | Reads own membership stats; cannot list other members. |
Persona impact
- Staff roles (
owner|admin|coachviaisStaffRole) 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 consumemaxMembers; activerole='coach'rows consumemaxCoaches; owners and admins consume neither. Pending invitations do not reserve seats. pending_invitationis a real membership row, not a placeholder — it shows up ingetUserMembershipsso the new user sees the org immediately after sign-in.
High-level capabilities
- 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 resolvedaccountStatus;?accountStatus=not_invitedfinds people with no Clerk account and no pending invitation. - Member detail —
GET /:orgId/members/:membershipId, with masked national ID andaccountStatus. - Member stats — classes-this-month, total-classes — for self (
/members/me/stats) or for any member (staff-only). - Update member —
PATCH /:orgId/members/:membershipId— change role/status/profile. - Invitations — list pending, create (single), resend, revoke. Each create both creates the Clerk invitation (sends email) and the DB row + shell membership.
- Bulk invite —
POST /:orgId/members/bulk-invitewith an array ofmembershipIds(imported users without Clerk accounts). Skips already-invited or already-linked users. Reportssent / skipped / failed. - Send invitation to existing member —
POST /:orgId/members/:membershipId/send-invitation— for a single imported member. - Accept pending invitations —
POST /invitations/accept-pending(auth required, throttled 10/min). Idempotent. Also runs from the Clerk webhook onuser.created. - Membership activation event —
MEMBERSHIP_ACTIVATEDemitted on every status flip toactive, 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_idFKs intousers. Webhook auto-accepts invitations. - organizations — org creation inserts the first owner membership; tier limits checked on invite.
- onboarding — invite step uses
bulk-inviteor per-emailcreateInvitation. - leads-crm —
OrganizationLeadsService.convertLeadinserts amembershipsrow withsourceLeadIdset and emitsMEMBERSHIP_ACTIVATED { source: 'lead_converted' }. - scheduling-bookings —
bookings.membership_idandclass_sessions.coach_membership_idboth point here; staff bypass enforced viaisStaffRole(membership.role).
Current status
Shipped. Notable bits:
requireMembershiphas a per-process 30s cache (MEMBERSHIP_CACHE_TTL_MS).invalidateMembershipCacheis 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
listMembersrequires 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_invitationshells 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).expiresAton invitations is checked lazily duringacceptPendingInvitations; expired ones get their status flipped toexpiredonly when the recipient tries to accept.