Memberships — Code Map
API
| File | Purpose |
|---|---|
apps/api/src/memberships/memberships.module.ts | Wires MembershipsController, InvitationsController, MembershipsService. Imports UsersModule + EventEmitter. |
apps/api/src/memberships/memberships.controller.ts | Org-scoped CRUD: list, detail, stats, invite, resend, revoke, bulk-invite, send-invitation, update. |
apps/api/src/memberships/invitations.controller.ts | Bearer-auth POST /invitations/accept-pending (throttled 10/min). |
apps/api/src/memberships/memberships.service.ts | All business logic + Clerk invitation orchestration + per-process 30s membership cache. |
apps/api/src/memberships/membership-events.ts | MEMBERSHIP_ACTIVATED and MEMBERSHIP_ACTIVATION_CONFIRMED event names + MembershipActivatedEvent payload type (joinPlanId set only by acceptJoinLink). |
apps/api/src/memberships/membership-lifecycle.ts | The end_date / joined_at contract every cancel and re-activation write follows; rejoinedAt() for re-activation. |
scripts/backfill-membership-end-date.ts | One-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.ts | resolveEmailOwnership(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.ts | search/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.ts | Term-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
| Method | Purpose |
|---|---|
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. |
bulkInvite | Stops 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
| Method | Path | Handler |
|---|---|---|
| GET | /organizations/:orgId/members | listMembers |
| GET | /organizations/:orgId/members/me/stats | getMyStats |
| GET | /organizations/:orgId/members/:membershipId | getMemberDetail |
| GET | /organizations/:orgId/members/:membershipId/stats | getMemberStats |
| PATCH | /organizations/:orgId/members/:membershipId | updateMembership |
| POST | /organizations/:orgId/members/bulk-invite | bulkInvite |
| POST | /organizations/:orgId/members/:membershipId/send-invitation | sendMemberInvitation |
| GET | /organizations/:orgId/invitations | listInvitations |
| POST | /organizations/:orgId/invitations | createInvitation (single) |
| POST | /organizations/:orgId/invitations/:invitationId/resend | resendInvitation |
| DELETE | /organizations/:orgId/invitations/:invitationId | revokeInvitation |
| POST | /invitations/accept-pending | acceptPendingInvitations (bearer) |
Web
| Component / Route | Description |
|---|---|
apps/web/src/app/[lang]/(protected)/dashboard/members/page.tsx + members-content.tsx | Paginated member list, filters, search. Sends status=active by default (rosterStatusParam). |
apps/web/src/app/[lang]/(protected)/dashboard/members/member-status-filter-options.ts | Status-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.tsx | Total (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.ts | hasBilling() / 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.tsx | UI for bulk-invite; reports Clerk rate-limit failures (bulk-invite-rate-limited). |
apps/web/src/components/role-router.tsx | Pending-membership screen; renders invitation-held when /users/me.invitationHeld. |
apps/web/src/components/members/card-registration-dialog.tsx | Card-registration flow (payment provider tie-in). |
apps/web/src/components/onboarding/invite-members-form.tsx | The onboarding-time invite list, hits createInvitation per email. |
i18n: members.*, members.invite.* in apps/web/src/i18n/dictionaries/. |
Events
| Event | Producer | Consumers (known) |
|---|---|---|
membership.invitation_held (audit action) | acceptPendingInvitations | Audit log. |
MEMBERSHIP_ACTIVATED | acceptPendingInvitations, OrganizationLeadsService.convertLead | Forms fan-out (onboarding compliance forms), analytics tracking, future welcome emails / push (per code comments). |
DB tables
| Table | Used as |
|---|---|
memberships | Owned. |
invitations | Owned (alongside Clerk invitation). |
users | Joined for list/detail/search. |
organizations | Joined for tier check (platform_tier). |
bookings, class_sessions, class_types, programs | Joined for computeMemberStats. |
Shared schemas
MembershipResponse,MembershipRole,MembershipStatus,MembershipPaymentStatus—libs/shared/src/lib/schemas/membership.schema.ts.rosterBucketValuesandrosterSummaryResponseSchema(GET /members/roster-summary:totalactive,invitedandformerbeside it) — same file.isStaffRole(role)— boolean helper that returns true forowner|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
| File | What it covers |
|---|---|
apps/api/src/memberships/memberships.service.unit.spec.ts | Invitation flow, acceptance, last-owner protection, tier limit, bulk-invite skip reasons. |
apps/api/src/memberships/invitation-email-proof.unit.spec.ts | Hold 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.ts | 50-target cap, Clerk 429 stop, other-error continue. |
apps/api/src/memberships/membership-end-date.int.spec.ts | Staff 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.ts | Import refusal at the tier cap, re-run, Arbox-inactive, Elite, localization. |
apps/api/src/memberships/roster-summary.int.spec.ts | Roster summary: total counts active records only; invited and former beside it; buckets sum to all three. |
| Web integration | apps/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). |