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— 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
| 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 limit (
maxMembers) only countsstatus='active'rows of any role. Staff invitations bypass the tier check (seecreateInvitation:if (!isStaffRole(role)) { … check }). 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, searchable (name/email), filterable by status/role/hasClerkAccount/profileComplete, sortable. - Member detail —
GET /:orgId/members/:membershipId, with masked national ID. - 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_invitationshell users can build up if invitations are never accepted; no scheduled cleanup observed.expiresAton invitations is checked lazily duringacceptPendingInvitations; expired ones get their status flipped toexpiredonly when the recipient tries to accept.