User
Purpose
Identifies a real person inside a single Supabase project. The platform runs three independent Supabase projects — admin (business panel), client (gym / tickets mobile + web), and superadmin — and users.users holds the union of admin + client identities only. Superadmin users never sync into this table.
Because UUIDs from different Supabase projects never collide, the same real person who uses both the business panel and the client app appears as two independent rows — one with scope = 'business', one with scope = 'client'. Within a single scope the identity stays unified: a business-Ivan that staffs three gyms still has one row.
Identity & key fields
- Primary key:
id(uuid) — by schema comment, this is the Supabase user id from the issuing project (admin OR client); NOT auto-generated by Postgres. email(text, NOT NULL).phone(nullable text).globalName(mapped from columnfull_name, nullable text).avatarUrl(nullable text).scope(nullable text; CHECKscope IS NULL OR scope IN ('business', 'client')) — discriminator pointing at the issuing Supabase project. Nullable in v1 to allow gradual backfill;AuthSyncService.validateBusiness/.validateClientpopulate it on each new auth-sync (see ADR global-user-identity v3 amendment).- No
createdAt/updatedAtcolumns at this table level.
The display fields (globalName, avatarUrl) are platform-global. Per ADR global-user-identity, every public-facing render reads users.globalName / users.avatarUrl plus the related User public profile row (bio, specializations, links, slug, verifiedAt, coverPhotoUrl) — no per-company identity override exists anymore. The display-role label per company (e.g. “yoga instructor at Gym X”) lives on Company member roleLabel, decoupled from the permission role enum.
Invariants
idandemailNOT NULL (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/users.schema.ts).idis the Supabase auth user id (per schema comment) — NOT agen_random_uuid()default (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/users.schema.ts).- GIN trigram index
users_global_name_trgm_idxonfull_name(globalName) usinggin_trgm_ops— enables contributor-name matching inpg_trgmactivity search Tier B (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/users.schema.ts, migration0043_pg_trgm_search_indexes.sql).
Business invariants:
- The
idof this row equals the Supabaseauth.users.idof the issuing project (admin OR client) — this table is a shadow of Supabase auth, not an independent identity store. - Email and phone uniqueness is enforced by Supabase per project, not by this table — the application must not assume
users.emailis an authoritative dedup key. The same email can legally appear in two rows when one isscope = 'business'and the otherscope = 'client'. - Single identity within a Supabase scope. A business-scope user staffing three gyms has exactly one row (one business-Ivan, three Company member rows). A real person who uses both the business panel and the client app has two independent rows — one per Supabase project — and they are independent identities (independent profiles, independent slugs, independent verifications). Unifying them across surfaces is out of scope today (see ADR global-user-identity v3 amendment).
- Superadmin users are NOT in this table — the superadmin Supabase project does not invoke
AuthSyncServiceand never writes here. Superadmin identity is JWT-bearer only (seesuperadmin-jwt.strategy.ts).
Lifecycle
No state column on this row.
- Create: on first Supabase signup or first reach of a TKT Space endpoint with a Supabase JWT.
- Mutation sources (auth-sync):
AuthSyncService.validateBusiness(payload)is invoked byAdminJwtGuard(admin Supabase) and writesscope = 'business';AuthSyncService.validateClient(payload)is invoked byClientJwtGuard(client Supabase) and writesscope = 'client'. Each method only ever creates / refreshes the row for its own scope; cross-surface writes are impossible by construction (each guard sees a different Supabase JWT issuer). Seetktspace-backend/libs/features/auth/src/lib/services/auth-sync.service.ts. - Update: when the user edits their global profile (
globalName,avatarUrl) — viaPATCH /api/client/meorPATCH /api/business/me/public-profile(symmetric per surface — see API surfaces). - Update — one-shot
globalNamebackfill on customer linking. InsideAuthSyncService.linkUnlinkedCustomersByEmail(tktspace-backend/libs/features/auth/src/lib/services/auth-sync.service.ts), when an offline Company customer row’suserIdbecomes set, the service does a follow-up conditional update:UPDATE users SET full_name = <oldest matching companyCustomer.name> WHERE id = $supabaseId AND full_name IS NULL. The chosen source row is the OLDEST offline customer (ORDER BY company_customer.created_at ASC, id ASC LIMIT 1) — deterministic across multi-company linking. Idempotent: subsequent links are no-ops becauseglobalNameis already set (see ADR global-user-identity D6). This branch only runs from the client-scope auth-sync (linking is a client-surface concern). - Delete: hard — performed via Supabase admin API. No soft-delete column today.
Per-company state — active / banned / inactive — lives on the joining rows in other contexts (Company member isActive, Company customer status), not here.
Future work
OPEN: a soft-disable mechanism (admin-side “deactivate user” without losing the row) is desired but not yet implemented. The current model only supports hard-delete via Supabase. Until then, “banning” a User platform-wide must be done either by deletion or by individually banning them on every company they participate in.
Relationships
- User profile (ENT-022) —
user_profile.user_id→users.users.id, UNIQUE, on-delete cascade. 1:1. Private preferences (locale, notification flags) only. - User public profile (ENT-042) —
user_public_profile.user_id→users.users.id, UNIQUE, on-delete cascade. 1:1. Public-facing identity (bio, specializations, links, slug). May not exist —GET /me/public-profilesynthesises all-null when absent. - Company member (ENT-019) —
company_member.user_id→users.users.id. 1:N (the same user may be a member of multiple companies). - Company customer (ENT-017) —
company_customer.user_id(nullable) →users.users.id. 1:N for online customers. - User activity favorite (ENT-015) —
user_activity_favorites.user_id→users.users.id, on-delete cascade. 1:N. - Device token (ENT-023) — 1:N, on-delete cascade.
- Notification (ENT-026) — 1:N, on-delete cascade.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — /me, /me/avatar, /me/notification-preferences, GET / PATCH /me/public-profile (UserMeDto, UpdateMeDto, UpdateNotificationPrefsDto, UpdateMyPublicProfileDto, UserPublicProfileDto). Client user edits the client-scope row only. | Swagger UI |
| business | yes — GET / PATCH /api/business/me/public-profile (symmetric with client; gated by AdminJwtGuard on the admin Supabase project). Business user edits the business-scope row only. Staff are otherwise accessed via Company member, not raw User rows. | Swagger UI |
| super-admin | no — the super-admin contract uses a separate Supabase project that never syncs into this table (see schema comment on sphereAuditLog.actorUserId) | Swagger UI |
Known gotchas / open questions
- The PK is not auto-generated; it must equal the Supabase auth user id of the issuing project. Rows are created via auth flow, not directly.
- Super-admin auth uses a separate Supabase project (per
sphere_audit_log.actor_user_idschema comment) — super-admin user ids do not match this table’s PK and must not be FK-linked. Superadmin never gets ausersrow. - Cross-surface read is allowed; cross-surface edit is not. A business admin can read a customer’s client-scope public profile via JOIN (
companyCustomer.userId → users → user_public_profile), and a client app can read a coach’s business-scope public profile the same way. Edits stay per-surface: each/me/public-profileendpoint operates only on its own surface’s user row (gated by the correspondingAdminJwtGuard/ClientJwtGuard). There is no endpoint that lets one surface mutate the other surface’s user. - The
notificationsEnabled/pushEnabled/emailEnabledpreferences live on the related UserProfile, not on this row. The public-facing fields (bio,specializations,links,slug,verifiedAt,coverPhotoUrl) live on the related User public profile — every public render readsusers.globalName+users.avatarUrlfrom THIS row AND the public-profile row in a single LEFT JOIN. - Per ADR global-user-identity D9 (amends ADR contributors-must-be-members to v4), every name/avatar/bio render goes through
member.user.*andmember.user.publicProfile.*. The previous per-company identity columns oncompanyMember(publicName,bio,avatarUrl,specializations,links) have been dropped — historical pre-migration values are kept in thecompanies.company_member_legacy_identitybackup table until release 2026.07. - Cross-schema FKs from other contexts (
company_member.user_id,company_customer.user_id,notifications.device_token.user_id, etc.) have no DB cascade when this row is deleted (deletion happens via Supabase admin API and triggers nothing in this database). The application is responsible for cascading cleanup — see Identity context Open questions. - A single User may simultaneously be a customer AND a staff member of the same company (e.g., the gym owner who attends classes there) — both join rows coexist. This is per-scope: within the business scope, a business-Ivan can hold both Company member and Company customer rows for the same company.
users.scopeis nullable in v1 to allow gradual backfill. Auth-sync writes it on every login, so orphan rows heal over time. A follow-up ticket flips the column to NOT NULL oncecount(*) WHERE scope IS NULL = 0is stable — see Improvement backlog.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Add a soft-disable mechanism (e.g.
isActive booleanordisabledAt timestamptzcolumn) so the admin can deactivate a User platform-wide without invoking Supabase hard-delete. The current model forces either deletion or per-company banning, which is fragile. - Add
createdAt/updatedAtcolumns to this table. Today the User row has no timestamps of its own; only the related UserProfile tracks them. - Define an explicit cascade contract for what happens on Supabase user deletion. Today no DB cascade fires across schemas, and the cleanup logic is scattered (or absent). A single owning service that fans out the cleanup would close the orphan-row risk.
- Consider a
lastLoginAtcolumn to support inactive-user reports and retention/GDPR workflows. - Treat email/phone as application-mirror only — explicitly document that the source of truth for these fields is Supabase
auth.users, not this table. A periodic resync job (or webhook on Supabase change) would prevent drift.