Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesUsers AuthUsers & Auth — Behavior

Users & Auth — Behavior

State

The users row has no explicit status enum — its lifecycle is conveyed by:

FieldState
clerk_id IS NULLImported user that has not yet signed in (Arbox / CSV import).
clerk_id IS NOT NULLLinked to a Clerk identity.
deleted_at IS NULLActive.
deleted_at IS NOT NULLSoft-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 uniqueusers.email UNIQUE. Imported rows can be linked to a fresh Clerk identity on first sign-in if the emails match (findOrCreateFromClerk line ~74).
  • Clerk ID is globally uniqueusers.clerk_id UNIQUE.
  • A user can have many memberships — one per (user, org). memberships UNIQUE on (user_id, organization_id).
  • Names are FitKit-authoritativesyncFromClerk only overwrites email and imageUrl. findOrCreateFromClerk backfills 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 flagNODE_ENV !== 'production' && TEST_AUTH_BYPASS === 'true'. Both must hold.

Golden paths

G1 — Brand-new user sign-up

  1. User completes Clerk sign-up at /{lang}/(auth)/sign-up.
  2. Clerk fires user.created webhook to POST /webhooks/clerk (signed via Svix).
  3. Webhook calls findOrCreateFromClerk(clerkId) which either creates a new users row from Clerk profile data, or links an existing email-matched row with clerk_id=NULL.
  4. Webhook calls acceptPendingInvitations(clerkId) — any pending invitations rows matching this email get accepted, converting pending_invitation memberships to active.
  5. Web frontend: user lands on /{lang}RoleRouter redirects based on memberships. If none, → onboarding.

G2 — Returning sign-in

  1. Clerk session resumes. Frontend hits GET /users/me with bearer.
  2. AuthGuard verifies token, looks up user by clerk_id, attaches to request.
  3. Controller serves cached user from @CurrentUser('user') — no extra DB hit for the user.
  4. Memberships fetched separately by MembershipsService.getUserMemberships.

G3 — Profile update

  1. PATCH /users/me with partial profile.
  2. Phone (if present) normalized via normalizeIsraeliPhone; emergency phone same.
  3. National ID (if present) validated and encrypted via NationalIdEncryptionService.encrypt.
  4. DB updated, cache invalidated, Clerk synced (firstName/lastName only) in parallel.
  5. Response returns the updated user with profileComplete recomputed.

G4 — Imported user first sign-in

  1. CSV import created {email, firstName, lastName} with clerk_id=NULL.
  2. Owner sent Clerk invitation (MembershipsService.sendMemberInvitation or bulkInvite).
  3. Member clicks email link, signs up via Clerk.
  4. Webhook user.created arrives. findOrCreateFromClerk sees the email-matched row with clerk_id=NULL, links it (sets clerk_id, imageUrl; backfills name only if currently null).
  5. acceptPendingInvitations flips the pending_invitation membership to active.
  6. MEMBERSHIP_ACTIVATED event emitted; downstream subscribers (forms fan-out, etc.) wake up.

G5 — Account self-deletion

  1. Member taps “Delete account” in app/web. DELETE /users/me.
  2. deleteSelf(clerkId, {skipClerk:false}):
    • Transaction: users.deleted_at=now; memberships.status='cancelled' + deleted_at=now; active subscriptions.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.
  3. account_deleted event emitted via tracking.

G6 — User deleted via Clerk dashboard

  1. Admin deletes the user in Clerk console.
  2. Clerk fires user.deleted webhook.
  3. Webhook calls usersService.deleteSelf(clerkId, {skipClerk:true}) — same DB cascade, no Clerk re-delete.

Edge cases & error states

ScenarioBehavior
Bearer missing or malformed401 ‘Missing or invalid authorization header’.
Bearer signature invalid / expired401 ‘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 membershipacceptPendingInvitations(clerkId) runs as a fallback to convert any pending invites that haven’t been processed yet.
PATCH /users/me with invalid Israeli IDUsersService.encryptNationalId throws 'Invalid Israeli ID'. Controller surfaces as 500 (TODO: verify whether this is mapped to 400).
PATCH /users/me with unrecognized phone formatnormalizeIsraeliPhone returns null; service stores the raw input.
Webhook with bad Svix signature400 ‘Invalid webhook signature’.
Webhook missing svix headers400 ‘Missing svix headers’.
Webhook for user.deleted with no DB rowdeleteSelf returns {id: clerkId} after a best-effort Clerk delete (which may itself fail and log a warn).
GET /users/:id with id != current user404 ‘User not found’ (intentional — only self-lookup allowed).
Imported user signs up under a different email than importedEmail mismatch → no linking; a brand-new row is created. The imported row remains an orphan until manually merged.

Side effects

EventSide effect
Webhook user.createdfindOrCreateFromClerk, tracking.identify, tracking.track('user_signed_up'), acceptPendingInvitations.
Webhook user.updatedsyncFromClerk (email + imageUrl only).
Webhook user.deleteddeleteSelf({skipClerk:true}).
PATCH /users/meDB update, clerk.users.updateUser (name only), tracking (not explicitly called but flows via identify on next login).
DELETE /users/meSoft-delete cascade + Clerk delete + tracking.track('account_deleted').

Permissions

EndpointAuthNotes
GET /users/meBearerSelf.
PATCH /users/meBearerSelf.
DELETE /users/meBearerSelf. Idempotent.
GET /users/:idBearerSelf only; 404 otherwise.
POST /webhooks/clerk@Public() + Svix signatureNot gated by AuthGuard; validated by Svix.
POST /invitations/accept-pendingBearer + 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.sessionId
  • x-test-role → request.auth.role (informational only)