Identity
Purpose
The platform identity layer — holding the union of business-scope + client-scope users. Superadmin users are NOT here — they live in a separate Supabase project and never sync into users.users. Every account that can sign in to the business panel or to the client (web / mobile) is a single User row here; per-user private preferences live in UserProfile; public-facing identity (bio, specializations, links, slug, verified badge) lives in User public profile — see ADR global-user-identity (and its v3 amendment) for the split rationale and the per-surface symmetry model.
Other contexts reference users by user_id (e.g. companies.company_member.user_id, companies.company_customer.user_id, notifications.device_tokens.user_id). The reference shape is the same across the codebase: a UUID matching users.users.id. Whether that user belongs to the business or client scope is recorded in users.scope (see below).
Boundaries
- In:
UserPK = Supabase auth user id (admin OR client project — never superadmin);UserProfile1:1 (private preferences);UserPublicProfile1:1 (public-facing identity, per-surface). - Out (owned by other contexts):
- Super-admin identities are NOT in this context — they live in a separate Supabase project,
superadmin-jwt.strategy.tsdoes NOT invokeAuthSyncService, and they have no FK back here (see Sphere audit log). - Per-company role of a user lives in Company member (
companyMembers.user_id). - Per-company customer record of a user lives in Company customer (
companyCustomers.user_id).
- Super-admin identities are NOT in this context — they live in a separate Supabase project,
Scope discriminator
users.scope (nullable text, CHECK scope IN ('business', 'client') when not NULL) records which Supabase project issued the underlying auth identity. The platform runs three Supabase projects — admin (business panel), client (gym / tickets mobile + web), superadmin — and the first two both feed users.users. UUIDs from different Supabase projects never collide, so a real person who uses both the business panel and the client app appears as two rows: one with scope = 'business', one with scope = 'client'. Unifying them across surfaces is a separate ADR.
- Who writes it.
AuthSyncService.validateBusiness(payload)(called fromAdminJwtGuard) writes'business';AuthSyncService.validateClient(payload)(called fromClientJwtGuard) writes'client'. Each method only ever creates / refreshes the row matching its own scope — cross-surface writes are impossible by construction. - Nullable in v1. Backfill on relations works (
companyMember.userId → 'business',companyCustomer.userId → 'client') but orphans (users who logged in once and never interacted) are ambiguous. Auth-sync writes the scope on every login, so orphans heal over time. A follow-up ticket flips the column to NOT NULL — tracked in Improvement backlog. - Why not superadmin in the enum. Superadmin users never enter this table at all, so they need no discriminator value here.
Entities
| ID | Entity | Role in this context |
|---|---|---|
| ENT-021 | User | Per-Supabase-project identity — one row per Supabase-authed person per scope (business OR client). Same email may legally appear in two rows when one is scope='business' and the other scope='client'. |
| ENT-022 | UserProfile | Per-user private preferences (locale, birth date, notification toggles) — 1:1 with User. |
| ENT-042 | User public profile | Per-surface public-facing identity (bio, specializations, links, slug, verified badge) — 1:1 with User. Symmetric edit endpoints on business AND client; cross-surface reads via JOIN; cross-surface edits forbidden. |
| ENT-044 | Scanner credential | Company-scoped login/password actor used by gate-scanner devices on the dedicated scanner surface — distinct from users.users, no Supabase identity. Provisioned by business admins (/api/business/.../scanners), consumed by the scanner mobile app via /api/scanner/auth/* and /api/scanner/bookings/verify. |
Backend implementation
- Modules:
libs/features/auth/,libs/features/user-profile/. - Surface routing: auth-sync writes from BOTH
AdminJwtGuardandClientJwtGuard; profile read/write is symmetric —GET / PATCH /api/client/me/public-profileANDGET / PATCH /api/business/me/public-profile, sharing service internals. - Drizzle schema:
libs/shared/data-access-db/src/lib/schema/users.schema.ts(pgSchema('users')) — definesusers(with thescopediscriminator),user_profile, anduser_public_profilein the same Postgres schema.
Cross-context relationships
- This context owns
users.users,users.user_profile, andusers.user_public_profile. - This context is referenced by:
companies.company_member(per-company role),companies.company_customer(per-company customer),notifications.device_tokens,notifications.notification(user inbox),notifications.message(sender),activities.user_activity_favorite. - All references from other contexts are bare UUID columns — see ADR cross-schema-references-without-fk.
Open questions
- OPEN: when a Supabase user is deleted, what cascades? Most cross-schema references have NO FK at all, so Postgres does nothing — application-level cleanup must be defined.