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

Memberships — Behavior

State machines

Membership status (memberships.status, enum membership_status)

FromToTrigger
(insert)activeOwner self-create on org-create; convertLead; acceptPendingInvitations for first-time / re-accept paths.
(insert)pending_invitationcreateInvitation for known or new email — shell membership inserted alongside the invitation row.
pending_invitationactiveacceptPendingInvitations (webhook on user.created, or fallback in GET /users/me).
pending_invitationcancelledrevokeInvitation.
activesuspendedupdateMembership (owner protected).
activecancelledupdateMembership; usersService.deleteSelf cascade.
anyactiveRe-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 safetyupdateMembership blocks demoting the last owner+active row, and blocks setting it to suspended or cancelled.
  • Tier-aware invite — non-staff invites count against maxMembers; staff invites bypass.
  • Activation event invariantMEMBERSHIP_ACTIVATED is emitted exactly once per transition to active. (Consumers must be idempotent — webhook retries are possible.)
  • Cache invalidationrequireMembership cache has 30s TTL. Direct membership writes don’t auto-invalidate; callers that mutate must call invalidateMembershipCache(orgId, userId) (TODO: verify whether updateMembership actually invokes it — appears not to in the read code).
  • Invitation expiryINVITE_EXPIRY_DAYS = 7. Checked only at accept time.
  • Search across users + membershipslistMembers filters by ilike against users.first_name, users.last_name, users.email.

Golden paths

G1 — Owner invites a single member

  1. POST /:orgId/invitations with {email, role:'member'}.
  2. Service:
    • requireMembership check (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 invitations row.
    • INSERT memberships shell with status='pending_invitation' (creates shell user if needed).
  3. Returns the invitation row.

G2 — New user accepts invitation

  1. User clicks email, signs up at Clerk.
  2. Webhook user.created lands at the API.
  3. findOrCreateFromClerk either creates or links the user row.
  4. acceptPendingInvitations(clerkId):
    • Find all invitations matching the user’s email (case-insensitive) with status='pending'.
    • For each: if expired, mark expired and continue. Otherwise transaction: if a membership row exists (pending_invitation), flip it to active. Else insert a fresh memberships row. Mark invitation accepted with accepted_at.
    • Emit MEMBERSHIP_ACTIVATED for each new activation.

G3 — Bulk invite imported members

  1. Owner has 50 imported members with clerk_id=NULL. POST /:orgId/members/bulk-invite with their membershipIds.
  2. Service fetches each (membership_id, user_id, role, email, clerk_id), and the org’s pending invitations.
  3. For each: skip if clerk_id exists or pending invite exists (with reason strings). Otherwise call Clerk + INSERT invitations.
  4. Returns {sent, skipped, failed, summary:{total, sent, skipped, failed}}.

G4 — Member self-stats

  1. GET /:orgId/members/me/stats.
  2. Service computes classesThisMonth and totalClasses by joining bookingsclass_sessionsclass_typesprograms with programs.organization_id=orgId and bookings.membership_id=callerMembershipId.

G5 — Update role/status/profile

  1. Owner PATCH /:orgId/members/:id with {role: 'coach'}.
  2. Service: requireMembership (owner/admin); load target; if target is owner, run owner-protection guards.
  3. Optional: if updates.profile present, call usersService.updateProfile(target.userId, updates.profile).
  4. UPDATE the membership row.

Edge cases & error states

ScenarioBehavior
Invite an existing active member400 ‘User is already a member or has a pending membership’.
Invite an email with a pending_invitation already400 ‘A pending invitation already exists for this email’.
Resend a non-pending invitation400 ‘Invitation is no longer pending’.
Revoke a non-pending invitation400 ‘Invitation is no longer pending’.
Member tries to invite403 ‘Only owners and admins can invite members’.
Admin tries to invite an owner403 ‘Only owners can invite owners’.
Demote last owner403 ‘Cannot change the role of the last owner’.
Suspend/cancel the owner403 ‘Cannot suspend or cancel the owner’.
Send-invitation to a member who has clerk_id already400 ‘Member already has a Clerk account’.
Tier exceeded (member invite)403 ‘Member limit reached (N/M). Upgrade your plan to add more.’.
Accept expired invitationSilently flips invitation to expired; no membership change; user not added.
Webhook auto-accept races with manual accept-pending POSTBoth transactions tolerate each other — the existingMembership branch handles already-active rows by skipping.
Cross-org GET /:orgId/members/:id404 ‘Member not found’.
requireMembership for non-active membership403 ‘Not a member of this organization’ (status=‘active’ filter in the SELECT).

Side effects

OperationSide effects
Create invitationClerk invitation email; DB invitation row; shell user (if email new) + shell membership.
Revoke invitationBest-effort Clerk revoke; status='revoked'; cancel the pending_invitation membership for that email.
Resend invitationBest-effort Clerk revoke of old token; new Clerk invitation; clerk_invitation_id + expires_at updated.
Accept pendingMemberships 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 profileProfile mutates users row (org-wide impact).

Permissions

Endpointowneradmincoachmember
GET /members✓ (returns all)
GET /members/me/statsselfselfselfself
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)selfselfselfself

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.