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). |
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.
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.
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.