Users & Auth — Behavior
State
The users row has no explicit status enum — its lifecycle is conveyed by:
| Field | State |
|---|---|
clerk_id IS NULL | Imported user that has not yet signed in (Arbox / CSV import). |
clerk_id IS NOT NULL | Linked to a Clerk identity. |
deleted_at IS NULL | Active. |
deleted_at IS NOT NULL | Soft-deleted. Clerk identity may or may not exist depending on the path. |
guided_tour_completed_at is a separate completion timestamp for the post-onboarding dashboard tour (FIT-93; see apps/web/src/components/guided-tour/).
Invariants
- Email is globally unique —
users.emailUNIQUE. Imported rows can be linked to a fresh Clerk identity on first sign-in if the emails match (findOrCreateFromClerkline ~74). - Clerk ID is globally unique —
users.clerk_idUNIQUE. - A user can have many memberships — one per (user, org).
membershipsUNIQUE on(user_id, organization_id). - Names are FitKit-authoritative —
syncFromClerkonly overwrites email and imageUrl.findOrCreateFromClerkbackfills name from Clerk only if the imported row’s name is null. - National ID is always encrypted at rest — plaintext never persisted. Reads return masked form. Setting it always validates Israeli ID checksum first.
- Account self-delete is idempotent — calling
deleteSelf(clerkId)twice returns{id: clerkId}the second time (no row to soft-delete). - Test bypass requires non-prod AND env flag —
NODE_ENV !== 'production' && TEST_AUTH_BYPASS === 'true'. Both must hold.
Golden paths
G1 — Brand-new user sign-up
- User completes Clerk sign-up at
/{lang}/(auth)/sign-up. - Clerk fires
user.createdwebhook toPOST /webhooks/clerk(signed via Svix). - Webhook calls
findOrCreateFromClerk(clerkId)which either creates a newusersrow from Clerk profile data, or links an existing email-matched row withclerk_id=NULL. - Webhook calls
acceptPendingInvitations(clerkId)— any pendinginvitationsrows matching this email get accepted, convertingpending_invitationmemberships toactive. - Web frontend: user lands on
/{lang}→RoleRouterredirects based on memberships. If none, → onboarding.
G2 — Returning sign-in
- Clerk session resumes. Frontend hits
GET /users/mewith bearer. AuthGuardverifies token, looks up user byclerk_id, attaches to request.- Controller serves cached
userfrom@CurrentUser('user')— no extra DB hit for the user. - Memberships fetched separately by
MembershipsService.getUserMemberships.
G3 — Profile update
PATCH /users/mewith partial profile.- Phone (if present) normalized via
normalizeIsraeliPhone; emergency phone same. - National ID (if present) validated and encrypted via
NationalIdEncryptionService.encrypt. - DB updated, cache invalidated, Clerk synced (
firstName/lastNameonly) in parallel. - Response returns the updated user with
profileCompleterecomputed.
G4 — Imported user first sign-in
- CSV import created
{email, firstName, lastName}withclerk_id=NULL. - Owner sent Clerk invitation (
MembershipsService.sendMemberInvitationorbulkInvite). - Member clicks email link, signs up via Clerk.
- Webhook
user.createdarrives.findOrCreateFromClerksees the email-matched row withclerk_id=NULL, links it (sets clerk_id, imageUrl; backfills name only if currently null). acceptPendingInvitationsflips thepending_invitationmembership toactive.MEMBERSHIP_ACTIVATEDevent emitted; downstream subscribers (forms fan-out, etc.) wake up.
G5 — Account self-deletion
- Member taps “Delete account” in app/web.
DELETE /users/me. deleteSelf(clerkId, {skipClerk:false}):- Transaction:
users.deleted_at=now;memberships.status='cancelled' + deleted_at=now; activesubscriptions.status='cancelled' + cancel_at_period_end=true + cancelled_at=now;device_tokens.deleted_at=now. - Outside transaction:
clerk.users.deleteUser(clerkId). Failure logs error but does not roll back DB.
- Transaction:
account_deletedevent emitted via tracking.
G6 — User deleted via Clerk dashboard
- Admin deletes the user in Clerk console.
- Clerk fires
user.deletedwebhook. - Webhook calls
usersService.deleteSelf(clerkId, {skipClerk:true})— same DB cascade, no Clerk re-delete.
Edge cases & error states
| Scenario | Behavior |
|---|---|
| Bearer missing or malformed | 401 ‘Missing or invalid authorization header’. |
| Bearer signature invalid / expired | 401 ‘Invalid token’. |
GET /users/me for a brand-new user (webhook hasn’t landed) | AuthGuard’s dbUser is null, controller calls findOrCreateFromClerk and lazily creates the row. |
GET /users/me and no active membership | acceptPendingInvitations(clerkId) runs as a fallback to convert any pending invites that haven’t been processed yet. |
PATCH /users/me with invalid Israeli ID | UsersService.encryptNationalId throws 'Invalid Israeli ID'. Controller surfaces as 500 (TODO: verify whether this is mapped to 400). |
PATCH /users/me with unrecognized phone format | normalizeIsraeliPhone returns null; service stores the raw input. |
| Webhook with bad Svix signature | 400 ‘Invalid webhook signature’. |
| Webhook missing svix headers | 400 ‘Missing svix headers’. |
Webhook for user.deleted with no DB row | deleteSelf returns {id: clerkId} after a best-effort Clerk delete (which may itself fail and log a warn). |
GET /users/:id with id != current user | 404 ‘User not found’ (intentional — only self-lookup allowed). |
| Imported user signs up under a different email than imported | Email mismatch → no linking; a brand-new row is created. The imported row remains an orphan until manually merged. |
Side effects
| Event | Side effect |
|---|---|
Webhook user.created | findOrCreateFromClerk, tracking.identify, tracking.track('user_signed_up'), acceptPendingInvitations. |
Webhook user.updated | syncFromClerk (email + imageUrl only). |
Webhook user.deleted | deleteSelf({skipClerk:true}). |
PATCH /users/me | DB update, clerk.users.updateUser (name only), tracking (not explicitly called but flows via identify on next login). |
DELETE /users/me | Soft-delete cascade + Clerk delete + tracking.track('account_deleted'). |
Permissions
| Endpoint | Auth | Notes |
|---|---|---|
GET /users/me | Bearer | Self. |
PATCH /users/me | Bearer | Self. |
DELETE /users/me | Bearer | Self. Idempotent. |
GET /users/:id | Bearer | Self only; 404 otherwise. |
POST /webhooks/clerk | @Public() + Svix signature | Not gated by AuthGuard; validated by Svix. |
POST /invitations/accept-pending | Bearer + Throttle(10/min) | Self. Idempotent. |
Test bypass headers (only with TEST_AUTH_BYPASS=true and NODE_ENV !== 'production'):
x-test-user-id→ request.auth.userId (Clerk-style identifier)x-test-session-id→ request.auth.sessionIdx-test-role→ request.auth.role (informational only)