Company customer
Purpose
A person known to a Company in two modes: online (userId set — they signed in via Supabase) or offline (userId=NULL — admin entered them manually with name/phone/email). This is the per-tenant CRM record. All bookings, customer passes, wallets, and broadcast deliveries are keyed off this row, not directly off the underlying User.
Identity & key fields
- Primary key:
id(uuid, defaultgen_random_uuid()). companyId(uuid, NOT NULL, FK →companies.company.id, on-delete cascade).userId(nullable uuid, FK →users.users.id) — set for online customers (linked to a Supabase user), NULL for offline.name,phone,email(nullable text) — used for offline customers; may also coexist withuserId.nameis editable by admin ONLY whenuserId IS NULL(offline). OnceuserIdis set, the read projection COALESCEs tousers.globalNameand adminPATCH /api/business/customers/{id}rejects anamebody field with HTTP 409 — see Invariants and ADR global-user-identity D5.status(enumcompany_customer_status:NEW,ACTIVE,VIP,BANNED, defaultNEW).bonusBalance(integer, default0) — bonus points (not minor currency), confirmed by DTO@ApiProperty({ description: 'Bonus points balance' })intktspace-backend/libs/features/activities/src/lib/controllers/activities-client.controller.ts:20and theBONUSpayment method comment “Оплатить бонусным балансом” increate-client-booking.dto.ts:22. Spent on bookings viaBONUSpayment method; debited/restored bybookings-client.service.ts.internalNotes(nullable text) — private to company staff.
The userId IS NULL / userId IS NOT NULL test is the implicit discriminator between offline and online customers. status is manually curated by the admin — there is no automatic transition from NEW to ACTIVE based on activity. bonusBalance is loyalty points (integer), not money — money lives in Wallet.
Invariants
- UNIQUE on
(companyId, userId)— one company-customer row per (company, platform user) (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts). - UNIQUE on
(companyId, phone)(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts). - UNIQUE on
(companyId, email)(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts). companyIdON DELETE CASCADE (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).userIdFK has no on-delete clause declared — defaults to NO ACTION (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).statusdefaults toNEW;bonusBalancedefaults to0(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).
Business invariants:
- Three per-company UNIQUE constraints —
(companyId, userId),(companyId, phone),(companyId, email)— at the DB level. The same real person can exist in different companies, but not duplicated within one company. - An online customer (
userIdset) may also havename/phone/emailpopulated — these are admin-side overrides / manual entries, not a second offline record. status='BANNED'blocks new booking creation (enforced inbookings.service.ts:305,:324,bookings-client.service.ts:662— throwserrors.booking.customer_banned).status(NEW/ACTIVE/VIP/BANNED) is curated only by admin viaPATCH /api/business/customers/{id}. There is no automatic transition (e.g.NEW → ACTIVEafter first booking does NOT happen today). OnlyBANNEDhas enforced code behaviour; the others are admin metadata.- Name write-guard (app-level, 409).
PATCH /api/business/customers/{id}REJECTS a request body containingname(any value, including null or empty string) whencustomer.userId IS NOT NULL— throwsConflictExceptionwith codeerrors.customer.name_locked. Enforced inCompanyCustomersService.update(libs/features/companies/src/lib/services/company-customers.service.ts). No DB CHECK constraint — clean 409s instead of 500s. - Display-name COALESCE projection. Every read path that displays a customer name MUST prefer
users.globalNamewhencustomer.userId IS NOT NULL, else fall back tocompanyCustomers.name. The service projection embodies this rule (coalesce(users.global_name, companyCustomers.name) AS name) so consumers don’t reinvent it. See ADR global-user-identity D5. nameLocked: booleanon read DTOs — every customer read DTO carriesnameLocked = (customer.userId IS NOT NULL). Client UI (mobile + web) uses it to surface the “name is now from your global profile” notice; business UI uses it to disable the name input pre-emptively.
Lifecycle
Status enum values: NEW, ACTIVE, VIP, BANNED.
- Create — online: auto-created on first cross-context interaction with the company (booking, pass purchase, etc.). Starts with
status=NEW. - Create — offline: explicit admin entry in the admin panel, name/phone/email required.
- Create — guest checkout (X2). A
companyCustomerrow withuserId = NULLis also created (or reused) when an unauthenticated visitor POSTs/api/client/guest/companies/:companyId/sessions/:sessionId/bookings. The guest service (bookings-guest.service.ts) calls intoBookingsService.resolveCustomerId(widened fromprivatetopublicfor X2) which performs the find-or-create by(companyId, phone OR email). Guest path additionally lower-cases + trims the email before lookup so two visitors typingBob@x.comandbob@x.comresolve to the same row. The(companyId, email)UNIQUE constraint at the DB level (see Invariants) is the safety net for the concurrent-insert race — the guest service retries once on Postgres23505to recover the lost-race row. Historical mixed-case rows are NOT backfilled; a guest typing the lowercase of an existing mixed-case row will create a new row —AuthSyncService.validateClientresolves the duplication at signup time because Supabase normalises email and both sides converge on the same lowercase value. Seespecs/guest-checkout-via-email-and-signup-nudge.mdAC-3 / ADR D3. - Linking (offline → online). When an offline customer signs up with an email/phone that matches an existing offline row,
AuthSyncService.linkUnlinkedCustomersByEmail(libs/features/auth/src/lib/services/auth-sync.service.ts) setsuserIdon the matching row(s). In the same transaction, ifusers.globalName IS NULL, the service backfillsusers.globalNamefrom the OLDEST offline customer row (ORDER BY company_customer.created_at ASC, id ASC LIMIT 1— deterministic across multi-company linking). From that moment on,companyCustomers.nameis locked (nameLocked = true) and all displays go throughusers.globalName. See ADR global-user-identity D6. X2 corollary: the same code path covers guest-created rows transparently — a guest who later signs up with the matching email gets their guest booking(s) auto-attached to their new account without any manual claim step (spec AC-7). - Status transitions: admin-only, via
PATCH /api/business/customers/{id}. No auto-promotion fromNEW. OnlyBANNEDhas enforced effect (blocks bookings);ACTIVEandVIPare admin labels with no code-level behaviour today. - Update: profile fields (
phone/email/internalNotesalways;nameonly whenuserId IS NULL— 409 otherwise), bonus balance adjustments, status. - Delete: hard delete. Cascades through bookings, customer passes, wallets per their FK config — destructive across the customer’s history in this company.
Relationships
- Company (ENT-016) —
companyId→companies.company.id, on-delete cascade. N:1. - User (ENT-021) —
userId(nullable) →users.users.id. N:1, optional. - Booking (ENT-003) — referenced by
bookings.bookings.customer_id. 1:N. - Customer pass (ENT-028) — referenced by
passes.customer_pass.customer_id. 1:N. - Wallet (ENT-040) — referenced by
wallet.wallet.customer_id. 1:N (one wallet per currency per customer). - Time slot attendee (ENT-014) — referenced by
time_slot_attendees.customer_id. 1:N. - Message recipient (ENT-025) — referenced by broadcast delivery.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — CustomerBriefDto, CustomerProfileDto referenced from client booking and wallet endpoints. CustomerProfileDto carries nameLocked: boolean so the client can surface “name comes from your global profile” | Swagger UI |
| business | yes — managed via libs/features/companies/src/lib/companies-admin.module.ts; surfaced indirectly via passes, sessions, etc. Read DTOs carry nameLocked: boolean. PATCH /api/business/customers/{id} returns 409 errors.customer.name_locked when name is in the body AND userId IS NOT NULL | Swagger UI |
| super-admin | no | — |
Known gotchas / open questions
- Three UNIQUE constraints scoped by company allow the same email/phone/userId to appear across companies — customers are per-tenant.
- An online customer (
userIdset) can additionally havename/phone/emailpopulated — the offline fields are not mutually exclusive with the user link. bonusBalanceis stored as an integer points (not minor currency) — used as an in-company loyalty currency, spent via theBONUSpayment method on bookings. The wallet balance (wallets.balance, real money) lives separately in the Wallet context.statusis admin-curated metadata — there is no automatic promotion fromNEWtoACTIVEafter first activity. This often surprises new product folks. OnlyBANNEDhas actual code-level enforcement.- The
online vs offlinedistinction is implicit inuserId IS NULL— there is no explicitkindcolumn. Reading code requires understanding this convention.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Automatic
NEW → ACTIVEtransition after first successful booking, if that is the intended product semantic. Today the status starts atNEWand stays there until an admin manually changes it, which is rarely done in practice — most rows sit inNEWforever. - Document what
ACTIVE/VIPactually grant. Today onlyBANNEDhas code effect; the other states are pure admin labels. Either give them real behaviour or simplify toACTIVE | BANNED. - Explicit
kindenum column (online | offline) instead of “guess fromuserId IS NULL” — improves code readability and lets the schema express the distinction directly. - Online↔offline merge workflow — today if an offline customer later signs up as an online user, there’s no first-class “merge these two rows” operation; the admin has to delete one and edit the other.
- Soft-delete for customers — hard delete cascades through bookings, customer passes, wallets, destroying historical records that finance / support may need.