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, or Clerk’s invitation.revoked webhook (revokeInvitationFromClerk). |
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). |
cancelled | cancelled + deletedAt | MemberErasureService.eraseMember — permanent erasure (see below). Terminal. |
invited | — | Never written: no API path sets status = 'invited' (grep "status: 'invited'" apps/api/src is empty; invitations create pending_invitation shells). The enum value survives and is read as a synonym of pending_invitation by member-status.ts and the accountStatus=invited roster filter. |
MEMBERSHIP_ACTIVATED event fires whenever a row transitions into active from any other state (including the create-with-active path).
The member activation gate (apps/api/src/member-activation-gate/) re-emits it as MEMBERSHIP_ACTIVATION_CONFIRMED, which is what the staff newMember mail and the new_member automation read. It holds a join-link signup that chose a plan (joinPlanId on the acceptJoinLink event, or the join form’s landing intent) until the purchase settles. See staff-newsletter behavior.
End date and “Left” (memberships.end_date, joined_at)
end_date is when a membership record stopped counting as a member. Analytics read it, and only it, for “Left” (members ribbon “Left this month”, members summary, growth chart “Left”, retention report). The contract lives in apps/api/src/memberships/membership-lifecycle.ts:
- Every transition into
cancelledstampsend_date = now:updateMembership(only on a real transition — a repeated cancel keeps the first date),markInvitationRevoked(revokeInvitation/revokeInvitationFromClerk),discardUnclaimedJoinShell,PaymentMonitoringService.releaseJoinMembership, self-delete (UsersService.softDeleteUser, keeps an existing date), platform-admin erase (AdminUsersService.erase). - Every transition out of
cancelledclears it:updateMembership(cancelled → anything), desk registration and both pending re-invite upserts, invitation acceptance, join-link acceptance, late-payment revival (WebhookProcessingService,PresaleCheckoutService), platform-admin restore (UsersService.restore). - A record that never held a member also clears
joined_at: a revoked invitation’s shell, a discarded join shell and a join-link registrant released for never paying.joined_atdefaults to the insert time, so without this every such shell would read as a member who joined and left. Re-activation restores it withCOALESCE(joined_at, now()). - Imports write
end_dateonly from the source file (membershipEnd); an Arbox-inactive row iscancelledwith no date.
Left = role = 'member', participation = 'trains', status = 'cancelled', end_date in the window, joined_at IS NOT NULL, not soft-deleted, and not a departure that predates its import (source = 'import' AND end_date <= created_at). An imported member cancelled in-app later counts. A cancelled row with no end_date has an unknown end and is never Left.
Lapsed (?paymentStatus=churned, the members.recent_churn card) is a different question: the member’s last plan ended and nothing live replaced it, whatever the membership record says. The roster row usually stays active. UI copy says “Plan lapsed”; the enum value stays churned for saved audiences.
Rows cancelled before end_date was written are filled once by scripts/backfill-membership-end-date.ts (dry-run by default, --apply to write): end_date := updated_at for cancelled, non-import rows without one, and joined_at := NULL for the never-joined shells it can identify from the audit trail and the invitations table (a revoked-invitation shell also needs no subscription and no booking on the membership, so a record that went live by another route keeps its joined_at). Imported rows are skipped: nothing records whether they were inactive at import or cancelled later.
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 (past_due is legacy; billing now writes debt from the first missed payment, kept while a plan cancelled for non-payment still owes). Set by the payments / subscriptions layer, not this module. Read here only on listMember responses.
This mirror is write-mostly and drifts — nothing gates on it. Do not read it to answer “is this member paying”; use the derived billing state below, computed from live subscription rows.
Account status (accountStatus)
accountStatus is a derived response field and list filter, not a memberships.status value. It keeps the live-record question and the sign-in question separate:
| Value | Rule |
|---|---|
activated | users.clerk_id exists. |
invited | No Clerk account and this organization has a live invitation for the user’s email (case-insensitive): status = 'pending', not deleted, expires_at > now. |
lapsed | No Clerk account, no live invitation, and at least one ended one: expired, revoked, or still pending but past expires_at. |
not_invited | No Clerk account and no invitation history at all. |
The rule lives once, in member-account-status.ts (accountStatusCondition for SQL, resolveAccountStatuses for rows in hand), and the list filter (?accountStatus=), the member detail, the activation callout (GET …/analytics/members/activation, which reports imported | invited | lapsed | activated, summing to total) and the members.invitations_lapsed insight all read it. Expiry is checked against expires_at directly, so a row is correct before the sweep below has marked it. List rows also carry pendingInvitationId — the live invitation behind invited, null otherwise — so a row can resend or revoke it.
An imported member can be active, hold a plan, and still be not_invited. An invited account status is likewise distinct from memberStatus: invited, which is the training-access state. The account-status filter and activation callout exclude cancelled memberships so a revoked shell never appears as awaiting an invitation.
Invitation lifecycle (invitations.status)
| From | To | Trigger |
|---|---|---|
| (insert) | pending | createInvitation, createMember (sendInvite), sendMemberInvitation, bulkInvite, createJoinInvitation. expires_at = now + 7 days, and every Clerk createInvitation (resend included) passes expiresInDays from the same constant, so Clerk’s ticket and our row run out together. |
pending | accepted | acceptPendingInvitations — user.created webhook, GET /users/me, POST /invitations/accept-pending, and Clerk’s invitation.accepted webhook when the email already belongs to a linked account (otherwise user.created follows and accepts). |
pending | pending (held) | acceptPendingInvitations on an invitation that is not source = 'join_link' while the account’s email ownership is unproven (see Email ownership proof below). Neither the invitation nor the pending_invitation shell changes; an audit row membership.invitation_held is written per held invitation per call. |
pending | revoked | revokeInvitation (staff; also revokes in Clerk) or Clerk’s invitation.revoked webhook (DB only). Both cancel the pending_invitation shell. Conditional on the row still being pending, so retries and races are no-ops. |
pending | expired | Daily InvitationExpirySweepService.expireStale (03:40 UTC, @ScheduledJob) for expires_at < now; also lazily in acceptPendingInvitations. Memberships are left untouched. Clerk emits no expiry event. |
GET /invitations returns only live invitations (pending, not deleted, not past expiry). A new invitation is blocked only by a live one: sendMemberInvitation and bulkInvite match emails case-insensitively and ignore deleted or expired rows, so a lapsed member can be re-invited. ?accountStatus= accepts a comma-separated list; the bulk-invite dialog asks for not_invited,lapsed, while the imported callout still counts not_invited only.
Email ownership proof and the held-invitation rule
An invitation carries data the inviting gym typed about the invitee (phone, birth date, encrypted national id, emergency contact), and staff roles grant access to a gym. An account that only claimed an email must not auto-claim it.
- Definition (
resolveEmailOwnership,apps/api/src/auth/email-ownership.ts; source of truth is ClerkpublicMetadata, no DB column):provenwhenemailProvenAtis set, orjoinTokenis absent (emailed invitation, Clerk Dashboard invite, OAuth, course-storefront email-code sign-up), orjoinInviteDelivery === 'email'(a join ticket delivered by email, which every public join request now is).unprovenotherwise — i.e. a join-ticket account whose sign-up link was handed to the browser, so whoever typed the address could create the account. Clerk unreachable ⇒unproven(fail closed). - Hold rule:
acceptPendingInvitationsaccepts an invitation only whensource === 'join_link'(self-served join; Clerk is never read for those) or ownership isproven. Every other invitation — any role, member included — is held. Expired invitations are still markedexpiredwithout a Clerk read. - Signed-in join link (
acceptJoinLink) honours the same rule. When the org holds a live non-join_linkinvitation for the user’s email and ownership isunproven, the join is refused unless the user is already an active member (403 { code: 'INVITATION_HELD' }, auditmembership.invitation_heldwithvia: 'join_link_accept') — whether or not apending_invitationshell exists, since joining around the invitation would strand it. Onlyjoin_linkinvitations are settled for such an account. A public link never grants staff access: a cancelled coach/admin, or the shell of a lapsed staff invitation, re-joins asmember. Only the shell of a live staff-sent invitation whose email isprovenkeeps its invited role. - How an account becomes proven: only a platform admin, via Mark email verified (
POST /admin/users/:id/verify-email-ownership,emailProvenBy='admin',emailProvenByActor), after confirming the person out-of-band. Accepting the invitation ticket is not proof: for an address that already has an account, Clerk signs the clicker into that account, which someone else may have pre-registered and still hold the password to. - What the user sees:
GET /users/mereturnsinvitationHeld: truewhen the user has apending_invitationshell and a live non-join_linkinvitation for their email and ownership isunproven. The web pending screen then showsinvitation-held(“Confirm it’s your email” — Taikan support must confirm the address; contact [email protected]). - Promotion inside one gym (
updateMembership) is not an invitation and is unaffected.
Who receives a join-link sign-up ticket
POST /join/:token/request-invite is public, and the Clerk invitation URL it mints authenticates its holder. For an address that already has a Clerk user, Clerk turns that URL into a sign-in ticket (__clerk_status=sign_in), which logs the holder in with no password or second factor. createJoinInvitation decides who gets the ticket from the address alone. No flag gates this:
| Address | Outcome |
|---|---|
Has a login (a users row with clerkId, or any Clerk user per clerk.users.getUserList({ emailAddress })) | 409 { code: 'ACCOUNT_EXISTS' } with nothing minted or written. The page shows join-account-exists with a Sign in button whose redirect_url is this join link (with ?plan=), and the signed-in join path takes over from there. Emits join.blocked with reason: 'account_exists'. If Clerk can’t be asked, the answer is 503 and no ticket (fail closed). |
Unclaimed profile someone else created (clerkId null and any non-deleted membership that isn’t this org’s join_link: imported, staff-invited, another gym’s join link; or no membership at all, e.g. a guardian named for a managed member) | The ticket is emailed to that address (delivery: 'email', joinInviteDelivery: 'email' metadata, so the resulting account is proven) and never returned. Nothing the form-filler typed is attached: no prefill, national ID or consent row, and the email greets nobody by name. A membership it creates here is source: 'invite', never join_link, so a repeat submit is emailed again. Response { deliveryMode: 'email', email }, whatever the flag. |
New address, or a re-submit of this org’s own join form (an unclaimed shell whose only memberships are this org’s join_link) | Emailed as well ({ deliveryMode: 'email', email }), with the form’s consent and drafts attached to the shell. See join-link email delivery. |
Why: the first login with an address inherits the unclaimed profile filed under it (findOrCreateFromClerk), along with its memberships, guardianships and invitations. Whoever holds the ticket gets that profile, so the ticket goes only to its inbox.
The ticket outlives this check: a browser ticket minted for an empty address stays live for 7 days. Claim time closes the gap. An unproven login takes a row only while it holds nothing but join-link self-registrations. If a gym has filed someone under the address since (an import, a desk registration, a staff invitation, a membership at another gym), the login gets 403 EMAIL_OWNERSHIP_REQUIRED until support verifies the address. See users-auth.
Every request counts against the invite caps (DEFAULT_JOIN_INVITE_CAPS, or the join-invite-caps payload) before anything is minted. A capped request mints nothing, writes nothing and sends nothing, so the public form cannot resend to a known member’s inbox on demand.
Join-link email delivery
POST /join/:token/request-invite never returns the Clerk sign-up URL. Not flagged.
| Response | { deliveryMode: 'email', email } (normalized address). The { url } shape stays in requestJoinInviteResultSchema so an older client still parses, but the API does not send it |
| Delivery | Clerk invitation minted with notify: false and publicMetadata: { joinToken, joinInviteDelivery: 'email' }; the API emails the URL itself in a branded, localized template (joinInviteLinkEmail, the form’s language wins over the org’s) |
| Web | join-cta shows the “Check your email” state (join-check-email, with the address and a “use a different email” back link) |
| Resulting account | proven (only the inbox owner can open the link) |
| Send failure | 503 { code: 'JOIN_EMAIL_DELIVERY_FAILED' }, no URL; the visitor resubmits (the invite row is reused). EmailService.send returning null (provider rejection or outage) counts as a failure, as does an API built without EmailService. Emits join.blocked with reason: 'email_delivery_failed'; success emits join.invite_emailed with deliveryReason: 'email_proof' |
| Invite caps | Always applied (join-invite-caps payload or DEFAULT_JOIN_INVITE_CAPS); the flag carries only the thresholds, because the form mails whatever address is typed |
Consent rows, compliance drafts, landing intent, invite caps and the seat gate all run before the email. A link opened in another browser starts a new join-session id for later funnel steps (same limitation as the App-Store round-trip). Accounts created earlier from a browser-delivered ticket stay unproven.
Registration status on the roster
- Status column: a row whose
memberStatusisno_plan/invitedand whoseaccountStatusis notactivatedshows the registration state (pending registration / invitation lapsed / not invited) instead, without the separate account badge. Rows with a plan state keep both badges. - Status filter:
invitedbecomes “Pending registration” and filtersaccountStatus=invited(this coverspending_invitationshells, which have no account and a live invitation); an “Invitation lapsed” option filtersaccountStatus=lapsed. “Former” selects?rosterBucket=former(suspended or cancelled records) — an alias of the donut slice, so picking either shows the other selected. It is worded “former” (he “לקוחות לשעבר”), never “left” / “עזבו”: Left is the cancelled-with-end_datemetric and Former also holds suspended records. The no-filter option reads “Current members” (he “לקוחות נוכחיים”, ru “Текущие клиенты”), because it lists everyactiverecord; “Active members” is reserved for the dashboards’trainingMemberConditioncount (training or leaving), a narrower set. - Roster bucket
invited: a member-role row withaccountStatus = invitedand no plan state — ranked afterformer,inDebt,overdue,endingandwithPlans, beforenoPlan, so a paying member who has not registered stays in the plan slices (plan state wins, as in the status column). Decided server-side inrosterBucketCondition, soroster-summary, the donut and?rosterBucket=invitedagree. Labelled “Pending registration”. - One list: “Pending invitations” shows staff invitations only; member rows get resend/revoke (invited) or send invitation (lapsed, not invited).
- Members by default: the list opens on
role=memberand keeps the role in the URL (role=allincluded), so a chosen role survives a reload; the role filter gainsstaff(owner/admin/coach, also accepted by the API).trainedThisWeekcounts member-role bookings only;newThisMonthexcludessource = 'import'memberships. - Action Center:
members.invitations_lapsed(owner/admin) counts lapsed member-role, non-cancelled memberships; info, warning at ≥ 10; drills into?accountStatus=lapsed.
Roster semantics and orgs without billing
GET /organizations/:orgId/members (MembershipsService.listMembers) is a register, not a billing view. It returns every non-deleted membership of the org with participation = 'trains', in any status, unless ?status= or ?role= narrows it. That API default is unchanged on purpose: agent tools and audience fan-outs rely on it.
The members screen opens on active records. The web list sends status=active (rosterStatusParam in member-status-filter-options.ts) unless a filter already names a population outside the active roster — memberStatus left / suspended / invited, rosterBucket former / invited, or any accountStatus (a pending_invitation shell is not active). Those requests go out exactly as before. The default is not written to the URL; the page’s server prefetch uses the same default request.
Roster summary. GET /organizations/:orgId/members/roster-summary (getRosterSummary, contract rosterSummaryResponseSchema): total counts active member records that train — the number the members screen prints. invited (invited / pending_invitation records) and former (suspended or cancelled, equal to byBucket.former) are reported beside it, never inside it. byBucket covers all three populations, so sum(byBucket) = total + invited + former. The ribbon prints Total with “+N invited, not joined yet” (he ”+{count} הוזמנו וטרם הצטרפו”) and “N former members” (he “לקוחות לשעבר”) beside it. The two “not registered” numbers on that card are different counts on purpose: the one beside Total is RECORD status (invited / pending_invitation memberships, whatever their account), while the donut’s “Pending registration” slice is ACCOUNT state (bucket invited: no account and a live invitation, whatever the record status, and only when no plan state ranks first). A CSV-imported active member with a live invitation is in the slice and not beside Total; an invited record whose invitation expired is beside Total and lands in the “no plan” slice. The MCP memberships.roster_summary and memberships.list descriptions say the same.
Nothing moves a membership out of active when its last subscription ends: the subscription lifecycle job cancels the subscription, and the membership row stays active with the member resolving to no_plan. The only billing path that cancels a membership is PaymentMonitoringService.releaseJoinMembership, which discards a join-link shell whose checkout never completed.
Orgs without billing. An org with no payment provider and no plans resolves every member to no_plan by design. The web decides this with hasBilling() (payment config status = 'active' or any plan, archived included; see payments behavior). When it is false the members screen shows the registration state in every status cell (activated included; suspended and departed rows keep their record state), drops the plan states from the status filter, hides the payment filter (a saved ?paymentStatus= link is dropped from both the list request and the URL, since there is no control left to clear it), the money donut slices, the at-risk callout and the row Enroll action, and the member page hides Manage plans and Enroll. The “left this month” churn tile stays. Unflagged: copy and layout only.
Billing filter (?paymentStatus= on the member list)
memberships.status = 'active' says a person is on the roster; it says nothing about money, which is what an owner actually wants from the members screen. GET /organizations/:orgId/members?paymentStatus=… filters the roster by billing situation, matched against subscriptions via EXISTS (never the write-mostly memberships.paymentStatus mirror — see above), in MembershipsService.listMembers (memberships.service.ts). Contract: listMembersQuerySchema in @taikan/shared.
paymentStatus | Meaning |
|---|---|
at_risk | The roster’s inDebt ∪ overdue on a live membership: a member seat, membership active, with a past_due subscription, a debt one, or any non-deleted row with debt_amount_in_cents > 0 (atRiskMemberCondition, member-status-predicates.ts). The members.at_risk_billing insight and analytics.getAtRiskMembers count the same predicate, so the card’s count is the size of this list. See the analytics definitions. |
ending | A live subscription (active/paused/past_due/debt) with cancel_at_period_end = true — still paying, on the way out. See below. |
paying | Holds an activated subscription, or a presale sale with the card on file (scheduled). |
not_paying | No live billing relationship at all — a bare pending checkout still counts as not paying, since no money has arrived; exactly the population an owner chases once a join link goes public. |
withdrawn | Backed out of a presale sale before opening day; nothing live now — presale churn. |
churned | Lapsed (UI “Plan lapsed”): previously activated, everything since ended. Not “Left”, which counts cancelled membership records — see “End date and Left” above. |
Two distinctions worth keeping straight:
- An abandoned checkout is
not_paying, not churn. Walking away from a payment page was never a membership; reporting it as churn would fill the owner’s churn list with closed payment pages. withdrawnandchurnedare separate on purpose. Mid-presale, the owner needs to see who backed out of a sale distinctly from who ended a membership that actually ran.
ending and at_risk are not mutually exclusive with each other the way the churn/no-plan states are — a past_due member who has also given notice matches both filters, since one is a money question and the other is a decision the member already made.
This filter list is a distinct concept from the per-row resolved member state (memberStatus, leaving | training | non_payment | payment_due | presale | paused | awaiting_payment | expired | no_plan | invited | suspended | left, access-first — non_payment (a non-course plan cancelled for non-payment with debt still owed) is checked before leaving and training, and a plan in its grace period keeps the member training with billingDebt and atDate = the pay-by day — see member-status.ts/resolveMemberStatus) that every list row also carries; a paymentStatus=ending member typically resolves to memberStatus: 'training' (or 'presale'), since giving notice doesn’t revoke access before the effective date.
The cancellations queue (paymentStatus=ending)
Filtering paymentStatus=ending additionally attaches an ending: EndingSummary | undefined object to each matched row (MemberWithUser.ending, endingSummarySchema in @taikan/shared) — omitted, not null, on every other query, so a client that doesn’t ask for ending sees no shape change. One bounded query over the page’s membership ids (MembershipsService.loadEndingSummaries), not N+1: cancellationNoticeAt, cancellationEffectiveAt, cancellationReasonCode, a retention: RetentionCase | null summary (kind, task id, owner id, acknowledged/contacted timestamps, contact attempts, outcome), and the retention case owner’s resolved ownerName. A membership can in principle carry more than one live subscription with a notice on it; ordering by cancellationEffectiveAt ascending and keeping the first row per membership picks the one ending soonest.
See ../subscriptions-plans/behavior.md for what opens and closes a retention case, and ../dashboard/behavior.md for the cancellations-queue UI this filter drives.
Desk registration (POST /organizations/:orgId/members)
MembershipsService.createMember registers a member at the counter with status: 'active' immediately, no Clerk account required.
It exists because the invite path cannot finish the job: an invited member sits at pending_invitation until they sign in, and both POST /members/:id/register-card and POST /members/:id/enroll refuse that status — so an owner could not take a card or assign a plan for someone standing in front of them. A person at the desk is a member.
- Email is optional (a walk-in who never uses the app is still a member); it is required only when
sendInviteis true. - The Clerk invitation is best-effort and never fails the registration — the membership is what the desk needs, and a mail provider hiccup must not cost it.
invitationSentin the response says what happened. - Same tier seat cap and role grant matrix as the invite path; an existing cancelled/suspended membership is reactivated rather than duplicated.
- Emits
MEMBERSHIP_ACTIVATED(membershipSource: 'manual'), so welcome fan-out and the CRM’s won-lead conversion behave as they do for any other activation.
Unverified logins (assertStaffMayRegister)
Desk registration, CRM lead conversion, Sales workspace conversion and member import attach an email to the roster directly, with no acceptance step. When the email already belongs to a login whose email is unproven (see Email ownership proof), they refuse with 422 { code: 'STAFF_REGISTRATION_UNVERIFIED' } before writing anything. Without this, a squatter who typed someone’s address into a public join form would receive the gym’s record, the details staff type in, or a staff role.
- Allowed:
- an email with no login;
- a
provenlogin; - a login already
active,pending_invitationorinvitedat this gym, since that path attaches nothing new: desk registration reports “already a member” and lead conversion links the existing member; - with role
memberonly: a login whose only live memberships are its ownjoin_linkregistration at this gym, i.e. the released, never-paid registrant coming back to the desk.
- Refused: everything else, including any staff role (coach, admin) for an unproven login.
- Staff see
members.unverifiedRegistration, inline in the wizard and the convert dialog: the email already tried to register and never confirmed; send an invitation and ask them to check their inbox; if they don’t remember registering, report it to Taikan support. The invitation then follows the held-invitation rule until support verifies. - Trace: each refusal writes
membership.registration_refused(org-scopedaudit_events;targetType: 'user',targetId= the login’susers.id; metadata{ via: 'desk_registration' | 'lead_conversion' | 'sales_conversion', attemptedRole, email, reason: 'email_ownership_unproven' }; actor, IP and user agent from the request) and aStaff registration refusedAPI warning log. Platform admins read it in the user’s ownership trail (Admin → Users). - The Sales workspace runs the check before its transaction (it reads Clerk) and uses 422, not 409, because the dialog treats any 409 as a version conflict.
- Import (CSV/Arbox,
ImportService.processSingleMember) runs the same check per row for an existing login with no membership here. A refused row is not attached and is counted asskipped. Its email is listed inresults.members.unverified, and the import toast lists those addresses with the same “send an invitation” guidance. The audit row carriesvia: 'import'andimportJobId;import_jobs.started_by(a user id) says who ran it, since import jobs run outside a request and have no actor.
Held invitations at a second gym
The “Confirm it’s your email” screen only covers people with no gym yet. When someone already active at gym A has an invitation from gym B held (invitationHeld: true with a pending_invitation membership):
- The member keeps using gym A and sees a banner (
invitation-held-banner,auth.invitationHeldBanner) naming gym B and pointing to Taikan support. - Gym B’s staff see Awaiting email verification on that row in Pending Invitations (
invitation-awaiting-verification).GET /organizations/:orgId/invitationsreturnsawaitingEmailVerification: truefor a pending invitation with amembership.invitation_heldaudit row. That is, the invitee tried to accept and was held. It comes from the hold’s own audit rows, with no Clerk call per invitation. - Member invitations are not in this table; they are acted on from the member rows, which don’t show the label yet. Staff invitations still do.
The dashboard drives this through RegisterMemberWizard (details → card → plan), which opens the gateway card page in a new tab and polls for the token rather than redirecting away and losing the half-finished registration.
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 audiences — active clients (
role='member') consumemaxMembers; active coaches consumemaxCoaches; owners and admins consume neither. Invitations and direct active-registration paths enforce the matching allowance. - 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. - Soft-deleted memberships are invisible —
listMembersandgetMemberDetailfilterdeletedAt IS NULL, so both self-serve account deletion and owner-initiated erasure remove the member from the org’s view. - Profile validation is channel-independent — HTTP DTOs and
memberships.updateagent input both enforce a valid phone (normalizePhone: local Israeli form, or any country with its+code), valid adult date of birth, and minimum profile-name lengths beforeMembershipsService.updateMembershipruns.
Seat caps
Tier maxMembers counts active role='member' memberships (-1 = unlimited, e.g. Elite). Enforcement points: invitation / desk-registration creation (assertRoleSeatAvailable) and the member import.
Import refusal (ImportService.fanOutMemberJobs, both CSV and Arbox): before any member job is enqueued the whole import is refused when active members now + new active candidates > maxMembers. New candidates are rows with an email, not Arbox-inactive (those become cancelled and never count), passing the email whitelist, and whose email is not already a membership of the org — so re-running the same file never trips the cap. A refused job is persisted as import_jobs.status='failed' with errorMessage set to the localized importExport.seatCapExceeded copy (org locale; names {limit}, {current}, {requested}) and zero results; nothing is created. The import dialog renders the message verbatim. Partial imports are deliberately not supported: an owner would be left with an unexplained subset.
Permanent erasure (right-to-be-forgotten)
DELETE /organizations/:orgId/members/:membershipId/permanent → MemberErasureService.eraseMember. Owner-only, target must not be an owner, and the membership must already be cancelled (deliberate two-step: cancel, then erase). The web danger zone shows the action only for cancelled members, with a type-the-name confirmation.
Org-scoped by design. Users are platform-level; the owner destroys the member’s data within their org only. When this org is the user’s entire footprint (no other live memberships, no active course entitlements sold by other orgs) the erasure escalates automatically: the users row is scrubbed in place (email becomes a deleted+{id}@deleted.taikan.fit tombstone, all PII columns nulled), device tokens are soft-deleted, notification prefs and agent conversations dropped, and the Clerk identity is deleted.
Data classes:
| Class | Treatment | Tables |
|---|---|---|
| Personal content | Hard-delete (+R2 objects) | progress photos, body metrics + settings, goals, workout results/PRs/assignments, feed items + comments + reactions, bookings, program enrollments, DM messages + attachments, inbox conversations, announcement reads, automation enrollments, member profile, pending invitations |
| Financial / legal | Keep row, scrub person | payment transactions + subscriptions (point at the soft-deleted membership), card tokens blanked in place, course entitlements revoked not deleted, signed forms + legal consents retained as legal artifacts, source lead contact fields nulled |
| Org-operational | Untouched | coach-authored content, schedules, task rows (structured linkedUserId cleared) |
Every erasure writes an audit_logs row (member.erased, actor, org, escalation flag, R2 object count) and a member_erased tracking event. R2 deletions run best-effort after the DB transaction commits; failures are logged for manual reconcile.
Known v1 gaps (accepted): exercise-comment bodies authored by the member survive (author is scrubbed on escalation); free-text task descriptions mentioning the member are not rewritten; the R2 form-upload binaries under {orgId}/forms/... are retained with the signed PDFs by design.
Member list search
Search (?search=) is term-based, not one substring over the whole box. The
value is split on whitespace/commas (max 6 terms, LIKE wildcards escaped) and
every term must ILIKE one of users.first_name, users.last_name,
users.email. So “Jane Doe” and “Doe, Jane” both find Jane Doe — a single
%Jane Doe% pattern matches no column on its own and used to return nothing.
Shared helper: apps/api/src/common/search-terms.ts.
Pagination is ?page= (1-based) + ?limit= (default 20, max
MEMBERS_LIST_MAX_LIMIT = 100); the response carries { data, total, page, limit }. The web table fixes the page size at 20 and shows its prev/next
footer only once there is a second page. Changing a filter or the search
resets to page 1.
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 against the invitee’s audience: clients use
maxMembers, coaches usemaxCoaches, and owner/admin roles are not tier-capped. - 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'. - Clerk’s
invitation.acceptedruns the same path when the account is already linked. - 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. - Invitations that are not
source='join_link'are skipped (held) while the account’s email is unproven — see Email ownership proof above. - Emit
MEMBERSHIP_ACTIVATEDfor each new activation.
- Find all
G3 — Bulk invite imported members
- Owner has imported members with
clerk_id=NULL. POST/:orgId/members/bulk-invitewith theirmembershipIds, or with{ allInvitable: true, excludeMembershipIds }to invite everyone invitable (resolved server-side). There is no per-request cap: the audience is bounded by the tier’s member limit (imports and desk registration are seat-capped), and Clerk’s hourly limit is handled in step 3. - Service fetches each (membership_id, user_id, role, email, clerk_id), and the org’s pending invitations.
- For each: skip if
clerk_idexists or a live invite exists for the email, case-insensitively (with reason strings). Otherwise call Clerk + INSERTinvitations. If Clerk answers429(instance limit: 100 invitations/hour) the loop stops: that target and every remaining one go tofailedwithreason: 'rate_limited', and the web dialog showsbulk-invite-rate-limitedwith the count. Retry in an hour. No seat check: targets are alreadyactiveimported members, so no new seat is consumed. - 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, code INVITE_ALREADY_MEMBER, body carries membershipId, membershipStatus, role. The invite dialog offers “change role” (PATCH members/:membershipId). |
Invite an email with a pending_invitation already | 400, code INVITE_INVITATION_PENDING, body carries invitationId. The invite dialog offers resend or revoke. |
| Invite an email in mixed case | The address is lowercased before lookup, so it matches the stored user. |
| Invite an existing member while the plan seat cap is reached | Duplicate answer wins; the seat check runs after it. |
| 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 | 403 ‘Member limit reached (N/M). Upgrade your plan to add more.’ for clients, or 403 ‘Coach limit reached (N/M). Upgrade your plan to add more.’ for coaches. |
| Accept a staff-sent invitation with an unproven email (join-ticket account) | Held: invitation stays pending, shell stays pending_invitation, audit membership.invitation_held, /users/me.invitationHeld=true. Opening the invitation email link or an admin “Mark email verified” releases it. |
| Bulk invite with more than 50 ids | 400 ‘At most 50 members per batch’. |
| Clerk 429 mid bulk-invite | Remaining targets failed with reason: 'rate_limited'; loop stops. |
| 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. The invitation.revoked webhook does the same without the Clerk call. |
| Expiry sweep | status='expired' for pending rows past expires_at; no membership change. |
| Resend invitation | Best-effort Clerk revoke of old token; new Clerk invitation; clerk_invitation_id + expires_at updated. |
| Accept pending | Memberships activated (held invitations excluded); 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.
Managed members
Spec: managed-members/behavior.md §4.1. Behind the managed-members flag except where noted.
memberships.participation:trains(default) orguardian_only, an adult who is a member only to manage a child.guardian_onlyadults are not counted on the roster or in analytics, and can’t book for themselves on any route (409 guardian_only_cannot_book; seescheduling-bookings/behavior.md).- Promotion (ungated): a
guardian_onlyadult whose own plan goes live (activeorscheduled: paid, free, staff-assigned or a completed presale) becomestrains(promoteToTrainsOnOwnPlan,apps/api/src/memberships/participation.ts:15). Apendingcheckout promotes nothing; an abandoned checkout leaves the adultguardian_only. Paying for a child’s plan never changes the payer. - A child’s membership is an ordinary membership keyed on the child’s
usersrow (users.kind = 'managed', no email, no login). It is never shown as “not signed in” or “invite pending” (apps/api/src/memberships/member-account-status.ts:86-89). - Responsible adults filter:
GET …/members?responsibleAdults=truelists every adult with an active or pending link, training or not (memberships.service.ts:578). - Member detail: a guardian may read a child’s detail through
GuardianAccessService.actingMembership; anyone else gets 403. Staff see the household section and Add child.