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

Users & Auth — Data Model

Primary table: users in libs/db/src/lib/schema/users.ts. Related tables: member_profiles, legal_consents, device_tokens.

users

ColumnTypeNotes
iduuid PKdefaultRandom(). Internal canonical identifier.
clerk_idvarchar(255) UNIQUEClerk subject (payload.sub). Nullable for imported users that haven’t signed up yet.
emailvarchar(255) UNIQUE NOT NULLLowercased on read where it matters (acceptPendingInvitations does lower() compare). Globally unique.
first_nametextFitKit-authoritative. Webhook never overwrites.
last_nametextSame.
image_urltextSynced from Clerk on user.updated.
phonevarchar(50)Normalized via normalizeIsraeliPhone on PATCH. Fallback to raw input.
birth_datedateValidated against isValidDob (past date, age 13-120).
gendergender enummale / female / non_binary / prefer_not_to_say.
emergency_contact_nametextRequired for profileComplete.
emergency_contact_phonevarchar(50)Normalized like main phone.
emergency_contact_relationshipvarchar(100)Free-text but typically constrained to relationshipValues from @fitkit/shared.
national_id_encryptedtextEnvelope-encrypted Israeli ID. Plaintext never stored. Read returns mask.
guided_tour_completed_attimestamptzFIT-93 post-onboarding tour completion.
created_at / updated_attimestamptz
deleted_attimestamptzSoft-delete via deleteSelf.

Constraints / indexes

  • UNIQUE: clerk_id, email.
  • No additional indexes declared.

Profile-complete required fields

const PROFILE_REQUIRED_FIELDS = [ 'firstName', 'lastName', 'phone', 'birthDate', 'gender', 'emergencyContactName', 'emergencyContactPhone', ] as const;

All 7 must be non-null for isProfileComplete to return true.

member_profiles

Per-(user, org) extended profile.

ColumnTypeNotes
iduuid PK
user_iduuid NOT NULLFK → users.id.
organization_iduuid NOT NULLFK → organizations.id.
address, city, country, zip, state, personal_id, additional_phonevariousAll optional gym-collected fields.
allow_sms, allow_mailing_list, medical_certboolConsents.
rfidvarchar(255)Physical RFID tag id.
is_childbool
arbox_user_idintMapping for Arbox import.
metadatajsonbCatch-all.
profile_embeddingvector(1024)Semantic (“the new yoga coach”, “members in Tel Aviv”). HNSW index.
embedding_hashvarchar(64)Gates re-embedding on no-op updates.

UNIQUE: (user_id, organization_id). One row per (user, org).

device_tokens

(Schema in libs/db/src/lib/schema/device-tokens.ts — not read in this audit but referenced by deleteSelf.) Soft-deleted on user delete so push stops.

Lifecycle of a users row

  1. Created by either:
    • Clerk webhook user.created (canonical path), or
    • Lazily by GET /users/me if webhook hasn’t landed, or
    • By an import job (Arbox/CSV) with clerk_id=NULL, name/email/phone pre-populated, awaiting linking.
  2. Linked — first sign-in for an imported row updates clerk_id, image_url, and backfills first_name/last_name only if currently null.
  3. Updated — name/phone/DOB/etc. via PATCH /users/me. Name pushed back to Clerk; nothing else is.
  4. DeletedDELETE /users/me (or webhook user.deleted) soft-deletes the row plus cascade (memberships → cancelled, subs → cancelled, device tokens → deleted).

Clerk → FitKit sync surface

DirectionFieldTrigger
Clerk → DBemailwebhook user.updated
Clerk → DBimage_urlwebhook user.updated
Clerk → DBclerk_id + initial first_name/last_namewebhook user.created / lazy create
DB → Clerkfirst_name, last_namePATCH /users/me via syncToClerk
Clerk delete → DBsoft-delete cascadewebhook user.deleted
DB delete → Clerkclerk.users.deleteUserDELETE /users/me

Nothing else syncs. Phone, DOB, gender, addresses, emergency contact, national ID are all FitKit-only.

National ID encryption

Service: NationalIdEncryptionService (apps/api/src/users/national-id-encryption.service.ts). Envelope encryption (TODO: verify which library/key source — likely crypto.createCipheriv with key from env). Stored as base64 ciphertext in national_id_encrypted. The maskNationalId helper returns ***1234 (last 4 digits).

Multi-org isolation pattern

users itself is global — no organization_id. Org isolation happens at the memberships table. To go from a user to an org’s view, the API takes both clerkId (caller) and orgId (resource), resolves to user → membership → org. See MembershipsService.requireMembership and ClassSessionsService patterns.

Soft-delete vs hard-delete

  • Soft for users (deleted_at).
  • Soft cascade: memberships (deleted_at), device_tokens (deleted_at), subscriptions (cancelled_at + cancel_at_period_end=true).
  • Hard delete on the Clerk side (the Clerk identity is removed so the email/phone is freed for a fresh signup).