Skip to content

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, default gen_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 with userId. name is editable by admin ONLY when userId IS NULL (offline). Once userId is set, the read projection COALESCEs to users.globalName and admin PATCH /api/business/customers/{id} rejects a name body field with HTTP 409 — see Invariants and ADR global-user-identity D5.
  • status (enum company_customer_status: NEW, ACTIVE, VIP, BANNED, default NEW).
  • bonusBalance (integer, default 0) — bonus points (not minor currency), confirmed by DTO @ApiProperty({ description: 'Bonus points balance' }) in tktspace-backend/libs/features/activities/src/lib/controllers/activities-client.controller.ts:20 and the BONUS payment method comment “Оплатить бонусным балансом” in create-client-booking.dto.ts:22. Spent on bookings via BONUS payment method; debited/restored by bookings-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).
  • companyId ON DELETE CASCADE (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).
  • userId FK 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).
  • status defaults to NEW; bonusBalance defaults to 0 (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 (userId set) may also have name/phone/email populated — these are admin-side overrides / manual entries, not a second offline record.
  • status='BANNED' blocks new booking creation (enforced in bookings.service.ts:305, :324, bookings-client.service.ts:662 — throws errors.booking.customer_banned).
  • status (NEW/ACTIVE/VIP/BANNED) is curated only by admin via PATCH /api/business/customers/{id}. There is no automatic transition (e.g. NEW → ACTIVE after first booking does NOT happen today). Only BANNED has enforced code behaviour; the others are admin metadata.
  • Name write-guard (app-level, 409). PATCH /api/business/customers/{id} REJECTS a request body containing name (any value, including null or empty string) when customer.userId IS NOT NULL — throws ConflictException with code errors.customer.name_locked. Enforced in CompanyCustomersService.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.globalName when customer.userId IS NOT NULL, else fall back to companyCustomers.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: boolean on read DTOs — every customer read DTO carries nameLocked = (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 companyCustomer row with userId = NULL is 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 into BookingsService.resolveCustomerId (widened from private to public for 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 typing Bob@x.com and bob@x.com resolve 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 Postgres 23505 to 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.validateClient resolves the duplication at signup time because Supabase normalises email and both sides converge on the same lowercase value. See specs/guest-checkout-via-email-and-signup-nudge.md AC-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) sets userId on the matching row(s). In the same transaction, if users.globalName IS NULL, the service backfills users.globalName from 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.name is locked (nameLocked = true) and all displays go through users.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 from NEW. Only BANNED has enforced effect (blocks bookings); ACTIVE and VIP are admin labels with no code-level behaviour today.
  • Update: profile fields (phone/email/internalNotes always; name only when userId 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) — companyIdcompanies.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

SurfaceExposedNotes
clientyes — 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
businessyes — 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 NULLSwagger UI
super-adminno

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 (userId set) can additionally have name/phone/email populated — the offline fields are not mutually exclusive with the user link.
  • bonusBalance is stored as an integer points (not minor currency) — used as an in-company loyalty currency, spent via the BONUS payment method on bookings. The wallet balance (wallets.balance, real money) lives separately in the Wallet context.
  • status is admin-curated metadata — there is no automatic promotion from NEW to ACTIVE after first activity. This often surprises new product folks. Only BANNED has actual code-level enforcement.
  • The online vs offline distinction is implicit in userId IS NULL — there is no explicit kind column. Reading code requires understanding this convention.

Recommendations

Forward-looking improvements suggested while filling this doc — not currently in place.

  • Automatic NEW → ACTIVE transition after first successful booking, if that is the intended product semantic. Today the status starts at NEW and stays there until an admin manually changes it, which is rarely done in practice — most rows sit in NEW forever.
  • Document what ACTIVE / VIP actually grant. Today only BANNED has code effect; the other states are pure admin labels. Either give them real behaviour or simplify to ACTIVE | BANNED.
  • Explicit kind enum column (online | offline) instead of “guess from userId 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.