Skip to content

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: User PK = Supabase auth user id (admin OR client project — never superadmin); UserProfile 1:1 (private preferences); UserPublicProfile 1: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.ts does NOT invoke AuthSyncService, 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).

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 from AdminJwtGuard) writes 'business'; AuthSyncService.validateClient(payload) (called from ClientJwtGuard) 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

IDEntityRole in this context
ENT-021UserPer-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-022UserProfilePer-user private preferences (locale, birth date, notification toggles) — 1:1 with User.
ENT-042User public profilePer-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-044Scanner credentialCompany-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 AdminJwtGuard and ClientJwtGuard; profile read/write is symmetric — GET / PATCH /api/client/me/public-profile AND GET / PATCH /api/business/me/public-profile, sharing service internals.
  • Drizzle schema: libs/shared/data-access-db/src/lib/schema/users.schema.ts (pgSchema('users')) — defines users (with the scope discriminator), user_profile, and user_public_profile in the same Postgres schema.

Cross-context relationships

  • This context owns users.users, users.user_profile, and users.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.