# Customers Module — Implementation Plan

**Spec:** docs/specs/2026-05-30-customers-module.md  ·  **Slug:** customers-module  ·  **Wave:** 2
**Depends on:** foundation-auth-rbac, foundation-design-system

## Goal
Deliver tenant-scoped customer account management: customers with multiple contacts, soft-archive lifecycle, a unified per-customer communications timeline, and portal-access invitation/management. This module is a foundation/core dependency — `projects-module`, `invoices-core`, `crm-support-center`, `tenant-portals`, `kb-module`, and the customer-statement specs all read its `customers`, `customer_contacts`, `customer_portal_users`, and `customer_communications` tables and consume its exported queries/routes.

## Architecture
- **DB (`packages/db`):** Four new tables — `customers`, `customer_contacts`, `customer_portal_users`, `customer_communications`. All carry `tenant_id UUID REFERENCES tenants(id)` for multi-tenant row isolation (enforced via the `tenantQuery` factory from `foundation-monorepo`, not RLS). FKs point at upstream `tenants(id)` and `users(id)` (from `foundation-auth-rbac`). Drizzle schema + drizzle-kit migration.
- **Query helpers (`packages/db/src/queries/customers.ts`):** Cursor-paginated list, detail-with-stats, contact CRUD, portal-user state transitions, communications list/append. All wrapped in `tenantQuery` so every statement is tenant-filtered.
- **API (`apps/zync-api`):** Hono route group mounted at `/api/customers`. Every route runs auth middleware then `requirePermission(...)` (from `packages/auth/src/middleware.ts`) using the `customers:read | customers:write | customers:delete` and `users:invite` permission keys seeded by `foundation-auth-rbac`. State-mutating routes validate the `Origin` header (handled by shared auth middleware). Portal invitation reuses the auth-rbac `invitations` table/flow with a `portal_role`.
- **UI (`apps/zync-app`):** `/customers` list route (DataTable + cursor pagination, TanStack Virtual above 200 rows) and `/customers/:id` detail route (Tabs: Overview, Contacts, Projects, Invoices, Support, Portal Users, Files, Communications). Add/Edit modal via `Dialog` + `Form` primitives. Built exclusively from `packages/ui` primitives — no raw HTML, no hardcoded colors/spacing.
- **System integration:** System events (invoice sent, portal invite, proposal viewed) auto-insert `customer_communications` rows through the notifications pipeline (`system-communications-notifications`); this module exposes the `appendCustomerCommunication` helper those producers call. Cross-module read tabs (Projects/Invoices/Support/Files) call the respective module queries when those modules exist; until then they render the design-system `EmptyState`.

## Tech Stack
- **Packages:** `packages/db` (Drizzle ORM, drizzle-kit, `@neondatabase/serverless` over Hyperdrive binding `DB`), `packages/ui` (primitives), `packages/auth` (middleware + permission keys).
- **Apps:** `apps/zync-api` (Hono on Cloudflare Workers), `apps/zync-app` (Vite + React, TanStack Query v5, TanStack Table v8, TanStack Virtual v3).
- **Bindings:** `DB` (Neon via Hyperdrive). Email send for outbound communications uses the configured email adapter from `system-communications-notifications` (invoked through the notifications API, not bound directly here).
- **Validation:** `zod` schemas shared between API route handlers and the React forms.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| C1 — Schema | 1 | `packages/db/src/schema/customers.ts`, migration | No (blocks all) |
| C2 — Queries & validation | 2, 3 | `packages/db/src/queries/customers.ts`, `packages/db/src/validation/customers.ts` | Tasks 2 & 3 parallel after C1 |
| C3 — API | 4, 5, 6, 7 | `apps/zync-api/src/routes/customers/*` | After C2; route files parallel |
| C4 — UI data layer | 8 | `apps/zync-app/src/modules/customers/api.ts` | After C3 |
| C5 — UI list/detail/modals | 9, 10, 11, 12 | `apps/zync-app/src/modules/customers/*` | After C4; components parallel |
| C6 — System events wiring | 13 | `packages/db/src/queries/customers.ts` (export), notifications hook | After C2 |

## Tasks

### Task 1: Database schema & migration
**Blocks:** 2, 3, 13  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/customers.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export customers schema)
- Create: `packages/db/migrations/<timestamp>_customers_module.sql`
**Steps:**
- [ ] Define the four tables in Drizzle (`pgTable`) matching the DDL below verbatim — UUID PKs, UUID→UUID FKs, TIMESTAMPTZ timestamps, JSONB address, BOOLEAN `is_primary`, inline CHECK enums.
- [ ] Add the `idx_customer_comms_customer` index and supporting list indexes.
- [ ] Generate the SQL migration with drizzle-kit and verify it targets Postgres (Neon), not SQLite.
- [ ] Re-export the new tables from the schema barrel.
**Schema / Interfaces:**
```sql
CREATE TABLE customers (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  company     TEXT,
  email       TEXT,
  phone       TEXT,
  address     JSONB,                       -- { street, city, state, zip, country }
  notes       TEXT,
  status      TEXT NOT NULL DEFAULT 'active'
              CHECK (status IN ('active', 'archived')),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_customers_tenant_status ON customers(tenant_id, status, created_at DESC);
CREATE INDEX idx_customers_tenant_name   ON customers(tenant_id, name);

CREATE TABLE customer_contacts (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  email       TEXT NOT NULL,
  phone       TEXT,
  role        TEXT,                        -- 'primary' | 'billing' | 'technical' | custom (free-text)
  is_primary  BOOLEAN NOT NULL DEFAULT false,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_customer_contacts_customer ON customer_contacts(tenant_id, customer_id);

CREATE TABLE customer_portal_users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  contact_id  UUID NOT NULL REFERENCES customer_contacts(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  portal_role TEXT NOT NULL DEFAULT 'customer_viewer',
  status      TEXT NOT NULL DEFAULT 'active'
              CHECK (status IN ('active', 'frozen')),
  invited_at  TIMESTAMPTZ,
  accepted_at TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_customer_portal_users_customer ON customer_portal_users(tenant_id, customer_id);

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
               CHECK (direction IN ('outbound', 'inbound', 'internal')),
  channel      TEXT NOT NULL
               CHECK (channel IN ('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 NOT NULL DEFAULT now()
);
CREATE INDEX idx_customer_comms_customer ON customer_communications(tenant_id, customer_id, sent_at DESC);
```
**Acceptance:**
- [ ] Migration applies cleanly against a Neon Postgres branch.
- [ ] All FKs are UUID→UUID; `is_primary` is BOOLEAN; `address` is JSONB; every enum is an inline CHECK matching the spec.
- [ ] Drizzle types compile and are re-exported from the schema barrel.

### Task 2: Query helpers
**Blocks:** 4, 5, 6, 7, 13  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/customers.ts`
**Steps:**
- [ ] Implement every query below wrapped in the `tenantQuery` factory so all statements are tenant-filtered.
- [ ] Cursor encoding: base64url of `{ created_at, id }`; list ordered `created_at DESC, id DESC`; clamp `limit` to a hard max of 100 rows (invariant from spec).
- [ ] `getCustomerWithStats` aggregates counts/sums. Where a producer module (invoices/projects/tickets) does not yet exist, the corresponding stat resolves to 0 — never error.
- [ ] Archive guard: `archiveCustomer` rejects with a typed `OpenInvoicesError` if the customer has open invoices (query the invoices table when present; treat absence as zero open invoices).
- [ ] Contact `is_primary` write helpers must clear the previous primary in the same transaction (only one primary per customer).
**Schema / Interfaces:**
```ts
// packages/db/src/queries/customers.ts
export interface Address { street?: string; city?: string; state?: string; zip?: string; country?: string }
export interface Customer {
  id: string; tenantId: string; name: string; company: string | null; email: string | null;
  phone: string | null; address: Address | null; notes: string | null;
  status: 'active' | 'archived'; createdAt: string; updatedAt: string;
}
export interface CustomerContact {
  id: string; customerId: string; tenantId: string; name: string; email: string;
  phone: string | null; role: string | null; isPrimary: boolean; createdAt: string;
}
export interface CustomerPortalUser {
  id: string; customerId: string; tenantId: string; contactId: string; userId: string;
  portalRole: string; status: 'active' | 'frozen'; invitedAt: string | null; acceptedAt: string | null;
}
export interface CustomerCommunication {
  id: string; tenantId: string; customerId: string;
  direction: 'outbound' | 'inbound' | 'internal';
  channel: 'email' | 'telegram' | 'ticket' | 'note' | 'system';
  subject: string | null; body: string | null; fromAddress: string | null; toAddress: string | null;
  relatedId: string | null; relatedType: string | null; sentAt: string; createdBy: string | null;
}
export interface CustomerStats {
  totalInvoices: number; totalPaid: number; outstandingBalance: number;
  openProjects: number; activeProjects: number; openInvoices: number;
}
export interface CustomerListPage { items: Customer[]; nextCursor: string | null; total: number }

export function listCustomers(tenantId: string, opts: { cursor?: string; limit?: number; status?: 'active' | 'archived'; search?: string }): Promise<CustomerListPage>;
export function getCustomerWithStats(tenantId: string, id: string): Promise<{ customer: Customer; stats: CustomerStats } | null>;
export function createCustomer(tenantId: string, input: Omit<Customer, 'id' | 'tenantId' | 'status' | 'createdAt' | 'updatedAt'> & { status?: 'active' }): Promise<Customer>;
export function updateCustomer(tenantId: string, id: string, patch: Partial<Pick<Customer, 'name' | 'company' | 'email' | 'phone' | 'address' | 'notes'>>): Promise<Customer>;
export function archiveCustomer(tenantId: string, id: string): Promise<Customer>; // throws OpenInvoicesError
export function listContacts(tenantId: string, customerId: string): Promise<CustomerContact[]>;
export function addContact(tenantId: string, customerId: string, input: Omit<CustomerContact, 'id' | 'customerId' | 'tenantId' | 'createdAt'>): Promise<CustomerContact>;
export function updateContact(tenantId: string, customerId: string, contactId: string, patch: Partial<Omit<CustomerContact, 'id' | 'customerId' | 'tenantId' | 'createdAt'>>): Promise<CustomerContact>;
export function removeContact(tenantId: string, customerId: string, contactId: string): Promise<void>;
export function listPortalUsers(tenantId: string, customerId: string): Promise<CustomerPortalUser[]>;
export function setPortalUserStatus(tenantId: string, customerId: string, portalUserId: string, status: 'active' | 'frozen'): Promise<CustomerPortalUser>;
export function listCommunications(tenantId: string, customerId: string, opts: { cursor?: string; limit?: number }): Promise<{ items: CustomerCommunication[]; nextCursor: string | null }>;
export function appendCustomerCommunication(tenantId: string, customerId: string, input: Omit<CustomerCommunication, 'id' | 'tenantId' | 'customerId' | 'sentAt'> & { sentAt?: string }): Promise<CustomerCommunication>;

export class OpenInvoicesError extends Error {}
```
**Acceptance:**
- [ ] Every query is tenant-filtered via `tenantQuery`; no statement omits `tenant_id`.
- [ ] `listCustomers` clamps `limit` to ≤100 and returns a stable cursor that round-trips.
- [ ] `archiveCustomer` throws `OpenInvoicesError` when open invoices exist.

### Task 3: Zod validation schemas
**Blocks:** 4, 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/validation/customers.ts`
**Steps:**
- [ ] Define request schemas reused by API handlers and React forms.
- [ ] `email` uses `z.string().email()`; `name` is required non-empty; `address` is an optional object of optional strings.
**Schema / Interfaces:**
```ts
export const addressSchema = z.object({
  street: z.string().optional(), city: z.string().optional(), state: z.string().optional(),
  zip: z.string().optional(), country: z.string().optional(),
}).partial();
export const createCustomerSchema = z.object({
  name: z.string().min(1), company: z.string().optional(), email: z.string().email().optional(),
  phone: z.string().optional(), address: addressSchema.optional(), notes: z.string().optional(),
});
export const updateCustomerSchema = createCustomerSchema.partial();
export const contactSchema = z.object({
  name: z.string().min(1), email: z.string().email(), phone: z.string().optional(),
  role: z.string().optional(), isPrimary: z.boolean().optional(),
});
export const createCommunicationSchema = z.object({
  direction: z.enum(['outbound', 'internal']),
  channel: z.enum(['email', 'note']),
  subject: z.string().optional(), body: z.string().min(1), toAddress: z.string().email().optional(),
}).refine(d => d.direction !== 'outbound' || (d.channel === 'email' && !!d.toAddress), {
  message: 'outbound email requires channel=email and to_address',
});
```
**Acceptance:**
- [ ] Schemas exported and importable by both `zync-api` and `zync-app`.
- [ ] `createCommunicationSchema` rejects an outbound payload lacking `to_address`.

### Task 4: Customer CRUD API routes
**Blocks:** 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/customers/index.ts`
- Modify: `apps/zync-api/src/index.ts` (mount route group at `/api/customers`)
**Steps:**
- [ ] Mount the route group behind auth middleware; the shared middleware validates the `Origin` header (`https://app.zync.is`) on all mutating requests before permission checks.
- [ ] Wire each route to `requirePermission(...)` per the permission table.
- [ ] Resolve `tenantId` from the session (`c.get('session').tid`) — never from the request body.
- [ ] Validate bodies with the Task 3 zod schemas; return 400 on failure.
**Schema / Interfaces:**
```
GET    /api/customers              ?cursor&limit(≤100)&status&search   requires customers:read
       → { items: Customer[], nextCursor: string | null, total: number }
POST   /api/customers              body: createCustomerSchema           requires customers:write → Customer
GET    /api/customers/:id          → { customer: Customer, stats: CustomerStats }  requires customers:read (404 if not found)
PATCH  /api/customers/:id          body: updateCustomerSchema           requires customers:write → Customer
DELETE /api/customers/:id          → archive (soft, status='archived')  requires customers:delete
       409 { error: 'open_invoices' } when OpenInvoicesError thrown
```
**Acceptance:**
- [ ] List endpoint never returns >100 rows and exposes `nextCursor`.
- [ ] Archiving a customer with open invoices returns 409.
- [ ] A session lacking the required permission gets 403; a cross-origin mutation is rejected before the handler runs.

### Task 5: Contacts API routes
**Blocks:** 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/customers/contacts.ts`
**Steps:**
- [ ] Implement contact CRUD nested under the customer.
- [ ] Setting `isPrimary: true` clears the prior primary (delegated to the Task 2 helper).
**Schema / Interfaces:**
```
GET    /api/customers/:id/contacts          requires customers:read  → CustomerContact[]
POST   /api/customers/:id/contacts          body: contactSchema   requires customers:write → CustomerContact
PATCH  /api/customers/:id/contacts/:cid     body: contactSchema.partial()  requires customers:write → CustomerContact
DELETE /api/customers/:id/contacts/:cid     requires customers:write → 204
```
**Acceptance:**
- [ ] Adding/marking a primary contact demotes any previous primary atomically.
- [ ] Permission gate `customers:write` enforced on all mutations.

### Task 6: Portal invitation & portal-user routes
**Blocks:** 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/customers/portal.ts`
**Steps:**
- [ ] `invite-portal` creates an invitation reusing the `foundation-auth-rbac` invitation flow (`invitations` table: token_hash SHA-256 stored, plaintext emailed; `expires_at` 7d) carrying `portal_role = 'customer_viewer'` and the `customer_id`/`contact_id` linkage; sends the portal email via the communications adapter and records a `customer_communications` row (channel `system`, direction `outbound`).
- [ ] On invite-accept (handled by the auth invitation accept path) a `customer_portal_users` row is created with `accepted_at` set.
- [ ] Freeze/unfreeze flip `customer_portal_users.status` and bump the invited user's `user_version` KV counter so the access JWT is revoked within ≤60s (per auth-rbac revocation model).
**Schema / Interfaces:**
```
POST   /api/customers/:id/contacts/:cid/invite-portal   requires customers:write
       → { invitationId: string } (sends portal link /portal/:tenantSlug)
GET    /api/customers/:id/portal-users                  requires customers:read → CustomerPortalUser[]
POST   /api/customers/:id/portal-users/:uid/freeze      requires users:invite → CustomerPortalUser (status='frozen')
POST   /api/customers/:id/portal-users/:uid/unfreeze    requires users:invite → CustomerPortalUser (status='active')
```
**Acceptance:**
- [ ] Invitation token is stored as a SHA-256 hash; plaintext appears only in the email.
- [ ] Freeze sets status `frozen` and increments the user-version counter; unfreeze reverses status.
- [ ] Freeze/unfreeze require `users:invite`; invite requires `customers:write`.

### Task 7: Communications API routes
**Blocks:** 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/customers/communications.ts`
**Steps:**
- [ ] List communications paginated, ordered `sent_at DESC` via the Task 2 helper.
- [ ] POST creates a row; for `direction='outbound', channel='email'` send via the configured email adapter (notifications API) then persist with `from_address` = tenant sending address, `to_address` from body, `created_by` = session user. For `direction='internal', channel='note'` persist only (no send).
**Schema / Interfaces:**
```
GET    /api/customers/:id/communications   ?cursor&limit(≤100)   requires customers:read
       → { items: CustomerCommunication[], nextCursor: string | null }
POST   /api/customers/:id/communications   body: createCommunicationSchema   requires customers:write
       → CustomerCommunication
```
**Acceptance:**
- [ ] Outbound email both sends and persists a row; internal note persists without sending.
- [ ] List is ordered newest-first and paginates by cursor.

### Task 8: zync-app data layer (TanStack Query hooks)
**Blocks:** 9, 10, 11, 12  ·  **Blocked by:** 4, 5, 6, 7
**Files:**
- Create: `apps/zync-app/src/modules/customers/api.ts`
**Steps:**
- [ ] Define typed fetch wrappers and TanStack Query v5 hooks against the API, importing the shared types/zod schemas from `packages/db`.
- [ ] List hook uses `useInfiniteQuery` keyed on `{ status, search }` with `getNextPageParam = (last) => last.nextCursor`.
- [ ] Mutations (`useCreateCustomer`, `useUpdateCustomer`, `useArchiveCustomer`, contact mutations, portal freeze/unfreeze, invite, send-communication) invalidate the relevant query keys.
**Schema / Interfaces:**
```ts
export function useCustomerList(params: { status?: 'active' | 'archived'; search?: string }): UseInfiniteQueryResult<CustomerListPage>;
export function useCustomer(id: string): UseQueryResult<{ customer: Customer; stats: CustomerStats }>;
export function useCustomerContacts(id: string): UseQueryResult<CustomerContact[]>;
export function useCustomerPortalUsers(id: string): UseQueryResult<CustomerPortalUser[]>;
export function useCustomerCommunications(id: string): UseInfiniteQueryResult<{ items: CustomerCommunication[]; nextCursor: string | null }>;
// + mutation hooks listed above
```
**Acceptance:**
- [ ] Infinite list hook drives cursor pagination and exposes `fetchNextPage`/`hasNextPage`.
- [ ] Mutations invalidate and refetch the affected queries.

### Task 9: Customer list page (`/customers`)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/modules/customers/CustomerListPage.tsx`
- Modify: `apps/zync-app/src/routes/index.tsx` (lazy-register the customers module route)
**Steps:**
- [ ] Render a `DataTable` (from `packages/ui`) with columns: Name/Company, Primary contact email, Active projects (count), Open invoices (count), Status (`Badge`).
- [ ] Header actions: "Add customer" (`Button` opens the Task 11 modal), search input (`Input` with `prefix` search icon, debounced → `search` param), status filter (`Select`: Active / Archived / All — archived hidden by default).
- [ ] Row click navigates to `/customers/:id`.
- [ ] When the loaded list exceeds 200 rows, wrap rows in `VirtualList` (TanStack Virtual) with `estimateSize: 64`, `overscan: 5`.
- [ ] Empty result renders the design-system `EmptyState` (one-sentence copy per `error-empty-states`) with an "Add customer" action.
- [ ] Use only `packages/ui` primitives, logical CSS (`ms-*`/`me-*`/`ps-*`/`pe-*`) for RTL, 8px-grid spacing, no hardcoded colors. Respect `prefers-reduced-motion` (rely on the 150ms opacity transitions already encoded in primitives; add no scale/lift animations).
**Acceptance:**
- [ ] List paginates via cursor and virtualizes above 200 rows (row height 64px, overscan 5).
- [ ] Status filter hides archived customers by default and can reveal them.
- [ ] Layout passes RTL preview (logical properties; no left/right hardcoding).

### Task 10: Customer detail page (`/customers/:id`) with tabs
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/modules/customers/CustomerDetailPage.tsx`
- Create: `apps/zync-app/src/modules/customers/tabs/OverviewTab.tsx`
- Create: `apps/zync-app/src/modules/customers/tabs/ContactsTab.tsx`
- Create: `apps/zync-app/src/modules/customers/tabs/PortalUsersTab.tsx`
- Create: `apps/zync-app/src/modules/customers/tabs/CommunicationsTab.tsx`
- Create: `apps/zync-app/src/modules/customers/tabs/CrossModuleTabs.tsx` (Projects, Invoices, Support, Files placeholders)
**Steps:**
- [ ] Header: customer name/company + actions `[Statement]` (link to `/customers/:id/statement`, the single statement entry point), `[Edit]` (opens Task 11 modal), `[⋯]` `DropdownMenu` with "Archive" and "Merge" (Merge links to spec 71 when present, else disabled).
- [ ] `Tabs` primitive with: Overview, Contacts, Projects, Invoices, Support, Portal Users, Files, Communications. Set `aria-controls`/`role="tab"` semantics provided by the Radix-based `Tabs` primitive.
- [ ] **Overview:** contact-info `Card`; `StatCard`s for total invoices, total paid, outstanding balance, open projects (from `stats`); recent-activity lists (last 5 invoices, last 5 tasks, last support ticket) — render `EmptyState` when a producer module is absent.
- [ ] **Contacts:** list contacts; add/edit/remove (Task 11 contact modal); "Mark primary"; "Invite to Portal" per contact (calls invite mutation).
- [ ] **Portal Users:** list `customer_portal_users` with status `Badge` (pending = `invited_at` set & `accepted_at` null / active / frozen); Freeze/Unfreeze buttons; "Revoke portal access" (delete/freeze per spec → uses freeze + revoke flow).
- [ ] **Communications:** newest-first timeline (Task 12 component) with `[Send message ▾]` dropdown ("Send email" → compose modal; "Add note" → internal note).
- [ ] **Projects / Invoices / Support / Files:** read-only lists via the respective module queries when those modules exist; otherwise `EmptyState`. Files tab honors `portal_can_upload_files` visibility (spec 136) and renders an "Uploaded by client" badge on client uploads when `portal-file-sharing` (spec 141) is present.
- [ ] All primitives from `packages/ui`; logical CSS for RTL; reduced-motion respected.
**Acceptance:**
- [ ] All eight tabs render; tab list is keyboard-navigable with correct ARIA roles.
- [ ] `[Statement]` is the only statement entry point and links to `/customers/:id/statement`.
- [ ] Portal Users tab shows pending/active/frozen and exposes freeze/unfreeze/revoke.

### Task 11: Add/Edit customer & contact modals
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/modules/customers/CustomerFormDialog.tsx`
- Create: `apps/zync-app/src/modules/customers/ContactFormDialog.tsx`
**Steps:**
- [ ] Build both forms with `Dialog` + `Form` (react-hook-form + zod) primitives, using the Task 3 schemas as resolvers.
- [ ] Customer fields: name (required), company, email (format-validated), phone, address (street/city/state/zip/country).
- [ ] Contact fields: name (required), email (required, format-validated), phone, role, isPrimary toggle (`Switch`).
- [ ] `FormField` auto-wires `aria-invalid`/`aria-describedby`; errors render in `<p role="alert">` (never color-only) per WCAG SC 3.3.1.
- [ ] Submit calls the create/update mutation; close on success; surface server 4xx as field/form errors.
**Acceptance:**
- [ ] Submitting without a name or with an invalid email shows an accessible inline error.
- [ ] Successful submit closes the dialog and refreshes the list/detail.

### Task 12: Communications timeline & compose modal
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/modules/customers/CommunicationsTimeline.tsx`
- Create: `apps/zync-app/src/modules/customers/SendMessageDialog.tsx`
**Steps:**
- [ ] Render the infinite timeline newest-first with per-entry iconography by `channel`/`direction` (email sent/received, ticket, note, system) and metadata line (via / from / to). "Load older items" triggers `fetchNextPage`.
- [ ] Inbound email entries linked to a ticket show "View thread"; ticket entries show "View ticket #…"; entries with `related_id`/`related_type` deep-link to the related record when that module exists.
- [ ] `[Send message ▾]` `DropdownMenu`: "Send email" opens `SendMessageDialog` (subject + body + to_address, posts `direction: 'outbound', channel: 'email'`); "Add note" opens a note form (body only, posts `direction: 'internal', channel: 'note'`).
- [ ] Decorative icons are not used as filler; channel glyphs are functional indicators only. Logical CSS for RTL; reduced-motion respected.
**Acceptance:**
- [ ] Timeline is newest-first and loads older items by cursor.
- [ ] "Send email" sends + appends a row; "Add note" appends an internal row without sending.

### Task 13: System-event communications wiring
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `packages/db/src/queries/customers.ts` (ensure `appendCustomerCommunication` is the canonical insert path)
- Create: `packages/db/src/queries/customer-communications-hooks.ts` (helper consumed by notifications producers)
**Steps:**
- [ ] Export a thin `recordSystemCommunication({ tenantId, customerId, channel, direction, subject, body, relatedId, relatedType })` helper that the notifications pipeline (`system-communications-notifications`) calls when emitting customer-facing system events (invoice sent, portal invitation, proposal viewed/accepted) so each auto-creates a `customer_communications` row (channel `system` or `email`, `created_by` NULL).
- [ ] Document the contract: system events insert via this helper; staff emails and manual notes insert via the Task 7 route. No duplicate inserts.
**Schema / Interfaces:**
```ts
export function recordSystemCommunication(input: {
  tenantId: string; customerId: string;
  direction: 'outbound' | 'inbound'; channel: 'email' | 'system' | 'ticket';
  subject?: string; body?: string; fromAddress?: string; toAddress?: string;
  relatedId?: string; relatedType?: string;
}): Promise<CustomerCommunication>;
```
**Acceptance:**
- [ ] A simulated "invoice sent" event creates exactly one `customer_communications` row with `created_by = NULL`.
- [ ] The helper is exported for cross-module consumption and is the single auto-insert path.
