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

Memberships — Code Map

API

FilePurpose
apps/api/src/memberships/memberships.module.tsWires MembershipsController, InvitationsController, MembershipsService. Imports UsersModule + EventEmitter.
apps/api/src/memberships/memberships.controller.tsOrg-scoped CRUD: list, detail, stats, invite, resend, revoke, bulk-invite, send-invitation, update.
apps/api/src/memberships/invitations.controller.tsBearer-auth POST /invitations/accept-pending (throttled 10/min).
apps/api/src/memberships/memberships.service.tsAll business logic + Clerk invitation orchestration + per-process 30s membership cache.
apps/api/src/memberships/membership-events.tsMEMBERSHIP_ACTIVATED and MEMBERSHIP_ACTIVATION_CONFIRMED event names + MembershipActivatedEvent payload type (joinPlanId set only by acceptJoinLink).
apps/api/src/memberships/membership-lifecycle.tsThe end_date / joined_at contract every cancel and re-activation write follows; rejoinedAt() for re-activation.
scripts/backfill-membership-end-date.tsOne-off: end_date := updated_at for cancelled non-import rows without one; clears joined_at on never-joined shells. Dry-run by default.
apps/api/src/member-activation-gate/Turns MEMBERSHIP_ACTIVATED into MEMBERSHIP_ACTIVATION_CONFIRMED, holding plan-bound join-link signups until the purchase settles: a member_activation_holds row (libs/db/src/lib/schema/member-activation-holds.ts) plus a BullMQ delayed decision, an early confirm on subscription.started, and a reconcile sweep for decisions lost from Redis.
apps/api/src/auth/email-ownership.tsresolveEmailOwnership(publicMetadata) (pure) and JOIN_INVITE_DELIVERY_EMAIL. Proven/unproven definition.
apps/api/src/memberships/dto/invite-member.dto.ts{email, role}.
apps/api/src/memberships/dto/bulk-invite.dto.ts{membershipIds?: uuid[]} or {allInvitable: true, excludeMembershipIds?: uuid[]}.
apps/api/src/memberships/dto/list-members-query.dto.tssearch/status/role/page/limit/sortBy/sortOrder/hasClerkAccount/profileComplete.
apps/api/src/memberships/dto/update-member.dto.ts{role?, status?, profile?: UpdateProfileDto}.
apps/api/src/common/search-terms.tsTerm-based free-text search: splits the box, escapes LIKE wildcards, ANDs one OR-group per term. Shared by the member list.

Invitation-hold surface in memberships.service.ts

MethodPurpose
acceptPendingInvitations(clerkId)Holds non-join_link invitations while ownership is unproven (reads Clerk only when such a live invitation exists). Audit membership.invitation_held.
acceptInvitationForEmail(email)invitation.accepted webhook path; runs acceptPendingInvitations for an already-linked account. Never treats the ticket as ownership proof (it can sign the clicker into a pre-registered account).
hasInvitationHeld(clerkId)Backs /users/me.invitationHeld: live non-join_link pending invitation for the user’s email and getEmailOwnership === 'unproven'.
createJoinInvitation(..., { deliverByEmail })JoinService.requestInvite always passes deliverByEmail: true, which adds joinInviteDelivery: 'email' to the Clerk invitation publicMetadata.
bulkInviteStops on Clerk 429 and fails the rest with reason: 'rate_limited' (also the path behind bulkInviteAllInvitable).

UsersService.getEmailOwnership / markEmailOwnershipProven (admin attestation only) (apps/api/src/users/users.service.ts) read / write the Clerk metadata. Import seat cap: ImportService.seatCapRefusal (apps/api/src/import/import.service.ts).

Routes

MethodPathHandler
GET/organizations/:orgId/memberslistMembers
GET/organizations/:orgId/members/me/statsgetMyStats
GET/organizations/:orgId/members/:membershipIdgetMemberDetail
GET/organizations/:orgId/members/:membershipId/statsgetMemberStats
PATCH/organizations/:orgId/members/:membershipIdupdateMembership
POST/organizations/:orgId/members/bulk-invitebulkInvite
POST/organizations/:orgId/members/:membershipId/send-invitationsendMemberInvitation
GET/organizations/:orgId/invitationslistInvitations
POST/organizations/:orgId/invitationscreateInvitation (single)
POST/organizations/:orgId/invitations/:invitationId/resendresendInvitation
DELETE/organizations/:orgId/invitations/:invitationIdrevokeInvitation
POST/invitations/accept-pendingacceptPendingInvitations (bearer)

Web

Component / RouteDescription
apps/web/src/app/[lang]/(protected)/dashboard/members/page.tsx + members-content.tsxPaginated member list, filters, search. Sends status=active by default (rosterStatusParam).
apps/web/src/app/[lang]/(protected)/dashboard/members/member-status-filter-options.tsStatus-filter options (incl. Former → rosterBucket=former, plan states only with billing) and rosterStatusParam, the active-records default.
apps/web/src/app/[lang]/(protected)/dashboard/members/members-ribbon.tsxTotal (active) with pending/former beside it, roster donut, callouts; money slices and at-risk callout only with billing.
apps/web/src/lib/billing.ts + apps/web/src/hooks/use-billing.tshasBilling() / useBilling(): the visibility rule for billing surfaces.
apps/web/src/app/[lang]/(protected)/dashboard/members/[id]/...Member detail.
apps/web/src/components/members/bulk-invite-dialog.tsxUI for bulk-invite; reports Clerk rate-limit failures (bulk-invite-rate-limited).
apps/web/src/components/role-router.tsxPending-membership screen; renders invitation-held when /users/me.invitationHeld.
apps/web/src/components/members/card-registration-dialog.tsxCard-registration flow (payment provider tie-in).
apps/web/src/components/onboarding/invite-members-form.tsxThe onboarding-time invite list, hits createInvitation per email.
i18n: members.*, members.invite.* in apps/web/src/i18n/dictionaries/.

Events

EventProducerConsumers (known)
membership.invitation_held (audit action)acceptPendingInvitationsAudit log.
MEMBERSHIP_ACTIVATEDacceptPendingInvitations, OrganizationLeadsService.convertLeadForms fan-out (onboarding compliance forms), analytics tracking, future welcome emails / push (per code comments).

DB tables

TableUsed as
membershipsOwned.
invitationsOwned (alongside Clerk invitation).
usersJoined for list/detail/search.
organizationsJoined for tier check (platform_tier).
bookings, class_sessions, class_types, programsJoined for computeMemberStats.

Shared schemas

  • MembershipResponse, MembershipRole, MembershipStatus, MembershipPaymentStatus — libs/shared/src/lib/schemas/membership.schema.ts.
  • rosterBucketValues and rosterSummaryResponseSchema (GET /members/roster-summary: total active, invited and former beside it) — same file.
  • isStaffRole(role) — boolean helper that returns true for owner|admin|coach.

Clerk integration

  • clerk.invitations.createInvitation({emailAddress, redirectUrl, ignoreExisting:true}) on create, resend, bulk-invite, send-member-invitation.
  • clerk.invitations.revokeInvitation(clerkInvitationId) best-effort on revoke and resend.
  • No Clerk org membership API used — invitation acceptance ties to Taikan memberships only.

Tests

FileWhat it covers
apps/api/src/memberships/memberships.service.unit.spec.tsInvitation flow, acceptance, last-owner protection, tier limit, bulk-invite skip reasons.
apps/api/src/memberships/invitation-email-proof.unit.spec.tsHold rule: join_link accepted without a Clerk read, staff-sent invites held when unproven, webhook proof marking.
apps/api/src/memberships/bulk-invite-caps.unit.spec.ts50-target cap, Clerk 429 stop, other-error continue.
apps/api/src/memberships/membership-end-date.int.spec.tsStaff cancel / re-activate and revoke write end_date (and joined_at); “Left” on the ribbon and growth chart excludes shells, pre-import departures and undated rows.
apps/api/src/import/import-seat-cap.unit.spec.tsImport refusal at the tier cap, re-run, Arbox-inactive, Elite, localization.
apps/api/src/memberships/roster-summary.int.spec.tsRoster summary: total counts active records only; invited and former beside it; buckets sum to all three.
Web integrationapps/web/src/app/[lang]/(protected)/dashboard/members/members-page.int.spec.tsx, members-ribbon.int.spec.tsx, members-roster-default.int.spec.tsx (active-records default and the filters that bypass it, Former option), members-no-billing.int.spec.tsx (registration-only column, no payment filter / money slices / Enroll, churn tile stays).