Skip to content

Customers

Crate: platyn-shop · Migration: 0004_shop.sql · Status: Schema built API planned

The table exists and is applied. The HTTP surface is planned.

A customer is a person at a company. Companies get billed; customers get emailed.

The split falls out of how a job actually moves:

  • The booster club president picks the shirts and approves the art.
  • The athletic director signs the purchase order.
  • The office administrator answers the phone in July.

Three people, one company, and only one of them can approve a proof. Collapsing them into one record means the approval email goes to whoever was typed in first.

erDiagram
    TENANTS   ||--o{ CUSTOMERS : owns
    COMPANIES ||--o{ CUSTOMERS : employs
    CUSTOMERS ||--o{ DEALS : "is the contact for"
    CUSTOMERS ||--o{ APPROVALS : "decides"
    CUSTOMERS ||--o{ APPROVAL_COMMENTS : "writes"
ColumnTypeNotes
idUUID PK
tenant_idUUIDapp.tenantsRLS key. ON DELETE CASCADE.
company_idUUID nullable → app.companiesON DELETE SET NULL — see below.
first_name, last_nameTEXT nullableBoth nullable. A record may start as just an email.
emailCITEXT nullableCase-insensitive at the database. Not unique — see below.
phone, titleTEXT
is_primaryBOOLEANDefault false.
notesTEXT
created_at, updated_at, archived_atTIMESTAMPTZ
SELECT app.apply_tenant_rls('app.customers');
CREATE INDEX customers_by_company ON app.customers (tenant_id, company_id)
WHERE archived_at IS NULL;
CREATE INDEX customers_by_email ON app.customers (tenant_id, email)
WHERE email IS NOT NULL;

company_id is nullable, and deleting a company sets it to NULL rather than cascading. A walk-in ordering twelve shirts for a bachelor party is not a company, and forcing the CSR to invent one produces a database full of companies named after individuals. ON DELETE SET NULL means removing a company orphans its contacts rather than destroying them — the contact history and their deals survive.

email is nullable and not unique. Some contacts are phone-only. And the same address genuinely appears twice: a person who works for two client companies, or a shared orders@ inbox used by several departments.

is_primary has no uniqueness constraint. A partial unique index on (company_id) WHERE is_primary would enforce one primary contact per company, and it is not there. Today that is the application’s job. It is a reasonable thing to add later; it is listed as an open question rather than described as a guarantee, because right now it is not one.

MethodPathPermission
GET/v1/customerscustomer.read
GET/v1/customers/{id}customer.read
POST/v1/customerscustomer.write
PATCH/v1/customers/{id}customer.write
POST/v1/customers/{id}/archivecustomer.write
GET/v1/customers/{id}/dealsdeal.read

Listing supports q over name and email, company_id, and keyset pagination.

production deliberately holds no customer.read — a press operator does not need the customer’s phone number to run a job, and the narrower the default grant, the less a compromised shop-floor terminal is worth.

  • A primary-contact constraint, if the product decides one primary per company is a rule rather than a convention:
    CREATE UNIQUE INDEX customers_one_primary ON app.customers (company_id)
    WHERE is_primary AND archived_at IS NULL;
  • An interaction timeline. customers.notes is a standing description, not a log. Calls, emails, and system events (“proof approved”, “deal moved to production”) want their own append-only table — much of which app.audit_log already captures and could project from.
  • Merge, with the same tombstone requirement as company merge.
  • Bounce and unsubscribe handling, so a hard bounce marks the address rather than silently failing to deliver every future proof.
  • Marketing consent, tracked separately from transactional email. Legally, and ethically.
  • CSV import, because every shop arrives with a spreadsheet.