# Customers Module

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `foundation-design-system`  
**Referenced by:** `projects-module`, `invoices-core`, `crm-support-center`, `tenant-portals`, `customer-statement`

---

## Overview

Manage a tenant's customer accounts. Each customer can have multiple contacts. Customers can be granted portal access (sub-tenant) for viewing their invoices, projects, and support tickets.

---

## Data Model

```sql
customers (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  company TEXT,
  email TEXT,
  phone TEXT,
  address JSONB,              -- { street, city, state, zip, country }
  notes TEXT,
  status TEXT DEFAULT 'active',  -- 'active' | 'archived'
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

customer_contacts (
  id UUID PRIMARY KEY,
  customer_id UUID NOT NULL,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  phone TEXT,
  role TEXT,                  -- 'primary' | 'billing' | 'technical' | custom
  is_primary BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ
)

-- Portal access: a contact can be invited to the tenant customer portal
customer_portal_users (
  id UUID PRIMARY KEY,
  customer_id UUID NOT NULL,
  tenant_id UUID NOT NULL,
  contact_id UUID NOT NULL,   -- must match a customer_contacts entry
  user_id UUID NOT NULL,      -- references users (created on invite accept)
  portal_role TEXT DEFAULT 'customer_viewer',
  status TEXT DEFAULT 'active',  -- 'active' | 'frozen'
  invited_at TIMESTAMPTZ,
  accepted_at TIMESTAMPTZ
)
```

---

## Features

### Customer list (`/customers`)

Table: `DataTable` component.  
Columns: Name/Company, Primary contact email, Active projects (count), Open invoices (count), Status.  
Actions: Add customer, search by name/email, filter by status.  
Row click → customer detail.

The list surface is an accessible region named `Customers`. Initial loading sets
`aria-busy="true"`. An initial request failure renders an alert reading
`Unable to load customers` with an enabled `Retry` action that refetches the list.

Rationale (2026-07-13): deterministic loading and recovery contracts make async
state regressions observable to users, assistive technology, and PR UI tests.

### List Performance

Customer list uses **cursor-based pagination**:

```ts
// API: GET /api/customers?cursor={encodedCursor}&limit=50
interface CustomerListResponse {
  items: Customer[]
  nextCursor: string | null
  total: number
}
```

**Virtual scroll (TanStack Virtual):** when list > 200 rows, activate. Row height: 64px. Overscan: 5 rows.

**Invariant:** list API max 100 rows per request. Bulk operations use the bulk-operations spec (spec 33), not the list endpoint.

### Customer detail (`/customers/:id`)

**Header actions:** `[Statement]` → `/customers/:id/statement` (account statement / כרטסת לקוח, spec 183), `[Edit]`, `[⋯]` (archive, merge — spec 71). The `[Statement]` action is the single entry point for the customer statement; spec 140 (`ar-aging-report`) links here rather than rendering its own.

Tabbed layout:

**[Tab] Overview**
- Contact info card (name, company, email, phone, address)
- Stats: total invoices, total paid, outstanding balance, open projects
- Recent activity: last 5 invoices, last 5 tasks, last support ticket

**[Tab] Contacts**
- List all contacts for this customer
- Add / edit / remove contacts
- Mark primary contact
- Invite contact to customer portal (sends invitation email)

**[Tab] Projects**
- List of all projects assigned to this customer (read via projects query)
- Link to project detail
- Implementation: `GET /api/projects?customer_id={customerId}`. Do not render placeholder text when the projects module is installed.

**[Tab] Invoices**
- List of all invoices for this customer
- Link to invoice detail
- Implementation: `GET /api/invoices?customerId={customerId}`. Do not render placeholder text when the invoices module is installed.

**[Tab] Support**
- List of support tickets from/about this customer
- Link to ticket detail
- Implementation: `GET /api/tickets?customer_id={customerId}`. Do not render placeholder text when the support module is installed.

**[Tab] Portal Users**
- List of portal users (from `customer_portal_users`)
- Pending / active / frozen status
- Freeze / unfreeze portal user
- Revoke portal access

**[Tab] Files**
- Files shared with this customer's portal (hosted by spec 141 `portal-file-sharing`; `portal_files` table)
- Upload (staff → client), see client uploads with "Uploaded by client" badge, download, delete
- Visibility/permission per `portal_can_upload_files` (spec 136 portal settings)

**[Tab] Communications**

Unified timeline of all outbound and inbound communications with this customer, across all channels:

```
┌──────────────────────────────────────────────────────────────┐
│  Communications                            [Send message ▾]  │
│                                                              │
│  Jun 01 16:30  ✉ Email sent — "Invoice INV-0042 issued"      │
│                  via: system  To: billing@acme.com           │
│                                                              │
│  Jun 01 14:12  📩 Email received — "RE: Invoice INV-0042"    │
│                  via: Gmail   From: billing@acme.com         │
│                  [View thread]                               │
│                                                              │
│  May 30 10:05  ✉ Email sent — "Welcome to our portal"       │
│                  via: Resend  To: contact@acme.com           │
│                                                              │
│  May 28 09:11  🎫 Ticket created — "Login issue"            │
│                  [View ticket #TKT-0023]                     │
│                                                              │
│  ─────────────────────────────────────── Load older items   │
└──────────────────────────────────────────────────────────────┘
```

Entry types shown in the communications timeline:
- System emails (invoice sent, proposal viewed, portal invitation)
- Inbound emails (received via Gmail/Outlook adapter) linked to tickets
- Support ticket creation / resolution events
- Proposal views / acceptance events (sourced from webhook events)
- Manual notes (free-text entries by staff)

**[Send message ▾]** dropdown: "Send email" (opens compose modal, sends via configured email adapter), "Add note" (internal note attached to customer, not sent).

```sql
CREATE TABLE customer_communications (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  customer_id  UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  direction    TEXT NOT NULL,   -- 'outbound' | 'inbound' | 'internal'
  channel      TEXT NOT NULL,   -- 'email' | 'telegram' | 'ticket' | 'note' | 'system'
  subject      TEXT,
  body         TEXT,
  from_address TEXT,
  to_address   TEXT,
  related_id   UUID,            -- invoice_id, ticket_id, proposal_id, etc.
  related_type TEXT,            -- 'invoice' | 'ticket' | 'proposal' | etc.
  sent_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  created_by   UUID REFERENCES users(id),  -- NULL for system-generated
  created_at   TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_customer_comms_customer ON customer_communications(tenant_id, customer_id, sent_at DESC);
```

System events (invoice sent, portal invite) auto-create `customer_communications` rows via the notifications pipeline. Staff-sent emails and manual notes create rows explicitly.

### Add / edit customer
- Modal form using `Dialog` + `Form` primitives
- Fields: name, company, email, phone, address
- Validation: email format, required name

### Archive customer
- Soft delete: `status = 'archived'`
- Archived customers hidden from default list (filter toggle to show)
- Cannot archive customer with open invoices (API validation)

---

## Customer Portal Invitation Flow

1. Tenant admin goes to Customer → Contacts tab
2. Clicks "Invite to Portal" on a contact
3. API creates invitation (same flow as `foundation-auth-rbac` invitation, but with `portal_role`)
4. Contact receives email with portal link (`/portal/:tenantSlug`)
5. Accepts → `customer_portal_users` record created

Portal access is strictly read-only (plus support tickets). See `tenant-portals` spec.

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View customer list | `customers:read` |
| View customer detail | `customers:read` |
| Create / edit customer | `customers:write` |
| Delete / archive customer | `customers:delete` |
| Invite portal user | `customers:write` |
| Manage portal access | `users:invite` |

---

## API Endpoints

```
GET    /api/customers                     → list (paginated, filterable)
POST   /api/customers                     → create
GET    /api/customers/:id                 → detail + stats
PATCH  /api/customers/:id                 → update
DELETE /api/customers/:id                 → archive (soft)
GET    /api/customers/:id/contacts        → list contacts
POST   /api/customers/:id/contacts        → add contact
PATCH  /api/customers/:id/contacts/:cid   → update contact
DELETE /api/customers/:id/contacts/:cid   → remove contact
POST   /api/customers/:id/contacts/:cid/invite-portal → send portal invitation
GET    /api/customers/:id/portal-users    → list portal access
POST   /api/customers/:id/portal-users/:uid/freeze
POST   /api/customers/:id/portal-users/:uid/unfreeze

GET    /api/customers/:id/communications     → list communications (paginated, desc)
POST   /api/customers/:id/communications     → create outbound email or internal note
       body: { direction: 'outbound'|'internal', channel: 'email'|'note', subject?, body, to_address? }
       For 'outbound' channel='email': sends via configured email adapter
       For 'internal' channel='note': stored only, not sent
       Requires: customers:write
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Contacts model | Separate table | One customer may have billing, technical, primary contacts |
| Portal access | `customer_portal_users` join table | Decouples portal auth from CRM data |
| Archive vs delete | Soft delete | Customer data referenced by invoices/projects — hard delete breaks history |
| Cross-module tabs | Reuse producer list APIs | Avoid duplicate customer-specific endpoints; tabs show live linked records once the producer module exists |
