Files
lynkeduppro-crm/LEADS_BACKEND_REQUIREMENTS.md
T
2026-07-23 18:40:00 +05:30

828 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Leads & Lead Verification — Backend Requirements
**Project:** LynkedUp Pro CRM (`lynkeduppro-crm`)
**Domain service:** `be-crm` (the CRM "data door")
**Modules covered:** `Leads`, `Lead Verification`
**Document status:** Implementation-ready. Derived entirely from the current frontend.
**Author basis:** Reverse-engineered from the completed frontend + the existing backend integration conventions already used by the Team, Mail, Inbox, Messenger, Media and Account modules.
---
> ## ⚠️ Read this first — current state of the frontend
>
> Both modules are **fully built on the UI side but wired to client-side mock data only**. There is **no live backend call anywhere** in `leads.tsx`, `leads-data.ts`, `verify.tsx`, or `verify-data.ts`. Every "write" today (Create Lead, Verify, Mark Unverified, Change Assignee, Update Status, Call, Email, Refresh) only pushes a **toast notification** — no persistence, no network request.
>
> This means the **entire** Leads and Lead Verification backend is greenfield. However, the frontend precisely defines the required **fields, enums, filters, sub-statuses, actions and workflows**, so this document treats the mock shapes as the authoritative contract.
>
> Wherever a control exists visually but has no behaviour yet, it is tagged **`[Frontend Placeholder / Backend Pending]`** so you know it is a real requirement even though the UI does not call it yet.
>
> The design below deliberately **reuses the existing `be-crm` architecture** (query/command data door, not REST). Do **not** invent a REST API — see §11 (Existing Backend Integration) for the mandatory conventions.
---
## Table of Contents
1. [Module Overview](#1-module-overview)
2. [Existing Frontend Features (exhaustive)](#2-existing-frontend-features-exhaustive)
3. [User Flows](#3-user-flows)
4. [Database Models](#4-database-models)
5. [Entity Relationships](#5-entity-relationships)
6. [API Contract — Actions (Queries & Commands)](#6-api-contract--actions-queries--commands)
7. [Validation Rules](#7-validation-rules)
8. [Reference / Enum Data](#8-reference--enum-data)
9. [Backend Services](#9-backend-services)
10. [Error Handling](#10-error-handling)
11. [Existing Backend Integration (mandatory conventions)](#11-existing-backend-integration-mandatory-conventions)
12. [Gaps, Discrepancies & Backend-Pending Items](#12-gaps-discrepancies--backend-pending-items)
---
## 1. Module Overview
### 1.1 Leads module
**Purpose:** The **Leads** module is the storm-restoration sales pipeline board. It holds prospective roofing jobs (door-knocked, referred, storm-chased, etc.) inside a hail-storm territory (the demo is the Plano, TX hail zone, storm event `2026-04-28`). Each lead is a rich record combining **contact**, **property**, **job details**, **insurance** and **assignment** information, and moves through a sales status pipeline.
**Frontend surface:** `src/components/dashboard/leads.tsx` (+ `leads-data.ts`), rendered inside the dashboard shell when the `leads` nav key is active.
**Core capabilities visible in the UI:**
- A **stat strip** (Total / New / Contacted / Appointed / Closed counts).
- A **board of lead cards** (avatar, priority ring, status pill, address, primary phone, source, canvasser, "updated" relative time).
- **Search** (by name / address / city / source / canvasser) and **status filter tabs**.
- A **lead detail modal** (Contact, Property, Job Details, Insurance, Assignment, storm banner).
- A **New Lead intake** with two modes: **Quick** (single condensed form) and **Full Form** (a 5-step wizard: Contact → Property → Job Details → Insurance → Assignment).
### 1.2 Lead Verification module
**Purpose:** The **Lead Verification** module is the verification desk that sits **upstream of Leads**. Freshly captured leads (door-knock / web form / storm canvass / referral / call-in) must pass an **identity + insurance + ownership + damage** verification workflow before they become working sales leads. The desk's terminal success action is literally *"Verified and pushed to New Leads"* — i.e., a verified verification record becomes a Lead with status `new`.
**Frontend surface:** `src/components/dashboard/verify.tsx` (+ `verify-data.ts`), rendered when the `verify` nav key is active.
**Core capabilities visible in the UI:**
- **Clickable stat tiles** (Verified / In Progress / Assigned / Pending / Unverified) that also act as status filters.
- A **data table** (Lead ID · Customer · Phone · Source · Assigned To · Status · Verification sub-status · Created · Actions).
- **Search** + **status / source / assignee** dropdown filters.
- A **row action set** (View, Verify, and a `⋯` menu: View Details, Verify Lead, Mark Unverified, Change Assignee, Reassign → In Progress, Move to Pending).
- A **verification detail modal** (Contact, Assignment, Verification Notes, and an **Activity timeline**).
- A **Refresh** button.
### 1.3 Relationship between the two modules
```
Intake sources Lead Verification desk Leads pipeline
(door knock, web form, ─────► verify identity / ownership / ─────► status = "new"
storm canvass, referral, insurance / damage → contacted → appointed
call-in) (verified | in_progress | → closed
assigned | pending | unverified)
```
- A **Lead Verification** record is the *pre-lead*. When it reaches `verified`, the system **creates / promotes** it into a **Lead** (`status = "new"`). This is the single hard link between the two modules and the most important cross-module command to implement (see `crm.leadVerification.verify` in §6).
- Both modules are gated by the **same** CRM permission: **`leads.manage`** (see `NAV_PERMISSION` in `sidebar.tsx`). There is no separate verification permission in the frontend.
- Both are **tenant-scoped** — every record belongs to the signed-in user's organization/tenant (the demo boots as `tenant-acme-01`).
### 1.4 User journey (end-to-end)
1. A lead is **captured** at an intake source and lands in the **Verification** queue as `pending` (unassigned) or `assigned`.
2. A **verification specialist** (Wade Hollis, Darlene Brooks, Roy Schaefer in the mock) is assigned and works the record through sub-statuses (`Verifying Identity``Confirming Ownership``Reviewing Insurance``Confirming Damage`).
3. The specialist either **Verifies** it (→ becomes a Lead, `status = new`) or **Marks Unverified**.
4. In the **Leads** module, a sales rep / canvasser works the new lead: **Contacted****Appointed****Closed**, updating status, priority, follow-up date, insurance/claim details and assignment along the way.
---
## 2. Existing Frontend Features (exhaustive)
Only features actually present in the code are listed. Each is tagged **Wired** (has real logic, even if only client-side state) or **`[Placeholder / Backend Pending]`** (UI exists, no behaviour beyond a toast).
### 2.1 Leads module features
| # | Feature | State | Notes / source |
|---|---------|-------|----------------|
| L1 | Lead board / card list | Wired (mock) | `LEADS` array rendered as `LeadCard`s |
| L2 | Header stat strip (Total, New, Contacted, Appointed, Closed) | Wired (mock) | `countByStatus`; `TOTAL_LEADS = 35` shown as total |
| L3 | Text search (name, address, city, source, canvasser) | Wired (mock) | `filtered` memo, case-insensitive substring |
| L4 | Status filter tabs (All / New / Contacted / Appointed / Closed) | Wired (mock) | `STATUS_TABS` |
| L5 | Lead detail modal (Contact, Property, Job, Insurance, Assignment, storm banner) | Wired (mock, read-only) | `LeadDetail` |
| L6 | Create Lead — Quick mode | **Placeholder** | `submit()` only validates name then toasts; no persistence |
| L7 | Create Lead — Full Form wizard (5 steps) | **Placeholder** | steps: Contact / Property / Job / Insurance / Assignment |
| L8 | Multiple phone numbers (with type Mobile/Home/Work + primary) | Wired (form state) | `addPhone/setPhone/removePhone` |
| L9 | Multiple email addresses | Wired (form state) | `addEmail/setEmail/removeEmail` |
| L10 | Site photos upload | **Placeholder** | `addPhoto()` just appends a label string `"Photo N"` — no real upload |
| L11 | Conditional field: Referral note (source = Referral) | Wired (form state) | `f.source === "Referral"` |
| L12 | Conditional field: Canvasser search (source = Door Knock) | Wired (form state) | `CanvasserSearch` typeahead over `REPS` |
| L13 | Priority picker (Low / Medium / High) | Wired (form state) | `PriorityPicker` |
| L14 | Urgency picker (Standard / High / Emergency) | Wired (form state) | `UrgencyPicker` |
| L15 | Rep assignment select (incl. "Unassigned") | Wired (form state) | `RepSelect`, options from `REPS` |
| L16 | Detail action: **Update Status** | **Placeholder** | footer button, no handler |
| L17 | Detail action: **Call** / **Email** | **Placeholder** | footer buttons, no handler |
| L18 | Empty state ("No leads match") | Wired | shown when `filtered.length === 0` |
**Not present in Leads (do not build unless requested):** edit-existing-lead form, delete, archive, bulk actions, tags editor (only a single static `tag` string exists), notes/comments thread, per-lead activity timeline, attachments list, pagination controls, sorting controls, export.
### 2.2 Lead Verification module features
| # | Feature | State | Notes / source |
|---|---------|-------|----------------|
| V1 | Stat tiles (Verified / In Progress / Assigned / Pending / Unverified) | Wired (mock) | `STAT_ORDER`, counts from `V_LEADS` |
| V2 | Stat tile click = status filter toggle | Wired (mock) | `setStatus(status === s ? "all" : s)` |
| V3 | Verification table (9 columns) | Wired (mock) | see column list in §1.2 |
| V4 | Text search (name, lead ID, phone, source, address) | Wired (mock) | `rows` memo |
| V5 | Status filter dropdown | Wired (mock) | mirrors stat tiles |
| V6 | Source filter dropdown | Wired (mock) | `V_SOURCES` |
| V7 | Assignee filter dropdown | Wired (mock) | `V_ASSIGNEES` |
| V8 | Row action: **View details** | Wired (mock) | opens `VerifyDetail` |
| V9 | Row action: **Verify** (quick) | **Placeholder** | toast only |
| V10 | Row `⋯` menu | Wired (open/close) | portalled dropdown |
| V11 | Menu: Verify Lead | **Placeholder** | toast only |
| V12 | Menu: Mark Unverified | **Placeholder** | toast only |
| V13 | Menu: Change Assignee | **Placeholder** | toast only |
| V14 | Menu: Reassign (→ In Progress) | **Placeholder** | toast only |
| V15 | Menu: Move to Pending | **Placeholder** | toast only |
| V16 | Detail modal — Contact / Assignment sections | Wired (mock, read) | `VerifyDetail` |
| V17 | Detail modal — Verification Notes | Wired (mock, read) | shown when `notes` present |
| V18 | Detail modal — Activity timeline | Wired (mock, read + derived) | `buildActivity()` synthesizes when absent |
| V19 | Detail footer: Verify Lead / Call | **Placeholder** | buttons, no handler |
| V20 | Refresh button | **Placeholder** | toast only ("Queue refreshed") |
| V21 | Row count "X of Y leads" | Wired (mock) | implies server total vs filtered count |
| V22 | Derived email / created-at when absent | Wired (mock) | `deriveEmail`, `deriveCreatedAt` |
**Not present in Verification (do not build unless requested):** create-verification-from-UI (records arrive via intake, never created here — mirrors the Inbox "items are never created here" pattern), delete, bulk verify, attachments, document upload, editable contact fields.
---
## 3. User Flows
### 3.1 Lead Verification status flow
Statuses (`VStatus`): `unverified`, `pending`, `assigned`, `in_progress`, `verified`.
Each status also carries a human **sub-status** string (`verification`).
```
intake (door knock / web form / storm canvass / referral / call-in)
┌──────────── pending ("Pending Review", unassigned) ────────────┐
│ │ │
│ [Change Assignee] │
│ ▼ │
│ assigned ("Assigned") │
│ │ │
│ [Reassign / start work] │
│ ▼ │
│ in_progress ("Verifying Identity" / │
│ "Confirming Ownership" / │
│ "Reviewing Insurance" / │
│ "Confirming Damage") │
│ │ │ │
│ [Verify]│ │[Mark Unverified] │
│ ▼ ▼ │
│ verified unverified ◄─────────────────────┘
│ ("Verified") ("Unverified")
│ │
│ ▼
│ ┌───────────────────────────────┐
└────►│ PROMOTE → create Lead(status = │
│ "new") + activity "Verified │
│ and pushed to New Leads." │
└───────────────────────────────┘
[Move to Pending] can send an assigned/in_progress record back to pending.
```
**Allowed transitions (enforce server-side):**
| From | To | Trigger command |
|------|----|-----------------|
| `pending` | `assigned` | `assign` (set assignee) |
| `pending` / `assigned` / `in_progress` | `in_progress` | `reassign` / `setInProgress` |
| `assigned` / `in_progress` | `pending` | `moveToPending` |
| `pending` / `assigned` / `in_progress` | `verified` | `verify` (→ promotes to Lead) |
| `pending` / `assigned` / `in_progress` | `unverified` | `markUnverified` |
| any | (assignee change) | `assign` / `changeAssignee` |
> The frontend does not restrict transitions (every action is available on every row), so treat the table above as the **recommended** guard set; at minimum, forbid transitions out of a terminal `verified` record except via an explicit re-open (backend-pending, not in UI).
### 3.2 Lead sales status flow
Statuses (`LeadStatus`): `new`, `contacted`, `appointed`, `closed`.
```
(created directly OR promoted from a verified verification)
new
│ [Update Status] / rep works the lead
contacted
appointed (adjuster / inspection appointment set)
closed (won/installed/paid — mock leans "won")
```
`priority` (`high` / `medium` / `low`) and `job.urgency` (`Standard` / `High` / `Emergency`) are independent of status and set at creation / update.
### 3.3 Create Lead (Full Form wizard) flow
```
Step 1 Contact → firstName*, lastName, phones[] (type + primary), emails[]
Step 2 Property → address, city, state (default "TX"), zip, propertyType, photos[]
Step 3 Job → source, leadType, workType, tradeType, urgency, notes
(source=Referral → referralNote; source=Door Knock → canvasser)
Step 4 Insurance → insCompany, claimNumber, claimStatus, adjusterName,
adjusterPhone, policyNumber
Step 5 Assignment→ assignRep (or Unassigned), priority, followUp date
→ [Create Lead] → crm.lead.create
```
Quick mode collects a condensed subset (firstName*, lastName, phone[0], address, city, state, zip, source, [referralNote|canvasser], priority, followUp) and submits the same `crm.lead.create`.
`*` = the only field the frontend currently enforces is a non-empty **name** (first or last).
---
## 4. Database Models
Storage assumptions follow the existing `be-crm` domain service (relational, multi-tenant). **Every table carries `tenant_id`** and is always filtered by it. Primary keys are opaque server IDs; the human-facing **codes** (`SAL-001`, `LD-V-001`) are separate, per-tenant, monotonic display identifiers.
Timestamps are stored as `timestamptz` (UTC, ISO-8601 on the wire). Relative strings like `"3d ago"` and pretty dates like `"Jun 4, 2026"` seen in the mock are **presentation-only** — the API returns ISO timestamps and the client formats them (see `relTime()` in `team-api.ts`).
### 4.1 `leads`
**Purpose:** One row per sales lead (the Leads board card + detail modal).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | uuid / string PK | no | gen | opaque server id |
| `tenant_id` | uuid/string FK → organizations | no | — | tenant scope; **indexed** |
| `code` | text | no | seq | display code `SAL-001`; **unique per tenant** |
| `first_name` | text | no | — | |
| `last_name` | text | yes | null | name = `first + last`, trimmed |
| `status` | enum `lead_status` | no | `'new'` | `new` \| `contacted` \| `appointed` \| `closed` |
| `priority` | enum `lead_priority` | no | `'medium'` | `high` \| `medium` \| `low` (UI Quick default = Medium) |
| `tag` | text | yes | `'Storm Zone'` | single label chip |
| `property_address` | text | yes | null | |
| `property_city` | text | yes | null | |
| `property_state` | text | yes | `'TX'` | 2-letter; UI default TX |
| `property_zip` | text | yes | null | |
| `property_type` | text | yes | null | see `PROPERTY_TYPES` enum |
| `source` | text | yes | null | see `LEAD_SOURCES` enum |
| `referral_note` | text | yes | null | only when `source = 'Referral'` |
| `lead_type` | text | yes | null | see §12 note on the enum conflict |
| `work_type` | text | yes | null | see `WORK_TYPES` |
| `trade_type` | text | yes | null | see `TRADE_TYPES` |
| `urgency` | enum `lead_urgency` | no | `'Standard'` | `Standard` \| `High` \| `Emergency` |
| `job_notes` | text | yes | null | "Field Notes" |
| `canvasser_id` | FK → members | yes | null | door-knock canvasser (REP id, e.g. `LUP-1040`) |
| `insurance_company` | text | yes | null | |
| `insurance_claim_status` | enum `claim_status` | yes | null | `Not Filed`\|`Filed`\|`Approved`\|`Paid`\|`Denied` |
| `insurance_claim_number` | text | yes | null | |
| `insurance_policy_number` | text | yes | null | |
| `insurance_adjuster_name` | text | yes | null | |
| `insurance_adjuster_phone` | text | yes | null | |
| `assigned_to_id` | FK → members | yes | null | rep working the lead |
| `follow_up_date` | date | yes | null | |
| `storm_zone` | text | yes | null | e.g. "E Plano / Spring Creek Pkwy" |
| `storm_date` | date | yes | null | e.g. `2026-04-28` |
| `storm_detail` | text | yes | null | e.g. `2.5" hail (severe)` |
| `source_verification_id` | FK → lead_verifications | yes | null | set when promoted from a verification |
| `created_by_id` | FK → members | yes | null | |
| `created_at` | timestamptz | no | now() | |
| `updated_at` | timestamptz | no | now() | drives "updated 3d ago" |
**Indexes:** `(tenant_id)`, `(tenant_id, status)`, `(tenant_id, code)` unique, `(tenant_id, assigned_to_id)`, `(tenant_id, source)`, `(tenant_id, updated_at desc)`.
**Search:** the board search covers name + property address + city + source + canvasser → back with a `tsvector` / trigram index over those columns.
**Example row (from `SAL-001`):**
```json
{
"id": "ld_9f2c…", "code": "SAL-001",
"firstName": "John", "lastName": "Martinez",
"status": "contacted", "priority": "high", "tag": "Storm Zone",
"propertyAddress": "4821 Spring Creek Pkwy", "propertyCity": "Plano",
"propertyState": "TX", "propertyZip": "75023", "propertyType": "Single Family",
"source": "Door Knock", "leadType": "Insurance",
"workType": "Roof Replacement", "tradeType": "Roofing", "urgency": "High",
"jobNotes": "Homeowner showed significant granule loss…",
"canvasserId": "LUP-1040",
"insuranceCompany": "State Farm", "insuranceClaimStatus": "Filed",
"insuranceClaimNumber": "CLM-2026-1000", "insurancePolicyNumber": "POL-080000",
"insuranceAdjusterName": "Marcus Powell", "insuranceAdjusterPhone": "(972) 700-3000",
"assignedToId": "LUP-…", "followUpDate": "2026-06-04",
"stormZone": "E Plano / Spring Creek Pkwy", "stormDate": "2026-04-28",
"stormDetail": "2.5\" hail (severe)",
"createdById": "LUP-1040", "createdAt": "2026-05-28T15:00:00Z", "updatedAt": "…"
}
```
### 4.2 `lead_phones`
**Purpose:** A lead has 1..N phone numbers, one primary.
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | **on delete cascade** |
| `number` | text | no | — | free-form display, e.g. `(469) 500-1000` |
| `type` | enum `phone_type` | no | `'Mobile'` | `Mobile` \| `Home` \| `Work` |
| `is_primary` | bool | no | false | exactly one true per lead (enforce) |
| `position` | int | no | 0 | display order |
**Indexes:** `(lead_id)`. **Constraint:** partial unique `(lead_id) where is_primary` to guarantee a single primary.
### 4.3 `lead_emails`
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | cascade |
| `address` | text | no | — | validate email format |
| `type` | text | yes | null | optional label |
| `is_primary` | bool | no | false | |
| `position` | int | no | 0 | |
### 4.4 `lead_attachments` (site photos) — `[Backend Pending]`
**Purpose:** Backs the "Site Photos" uploader (L10). The frontend currently only stores placeholder labels; the real implementation must use the existing media presign flow (§11.5).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | cascade |
| `content_ref` | text | no | — | IIOS object key from `crm.media.presignUpload` |
| `mime_type` | text | no | — | |
| `size_bytes` | bigint | no | — | ≤ `26_214_400` (25 MB) |
| `filename` | text | yes | null | |
| `uploaded_by_id` | FK → members | yes | null | |
| `created_at` | timestamptz | no | now() | |
### 4.5 `lead_status_history` (audit) — recommended
**Purpose:** Backs "Update Status" auditing (no dedicated Leads timeline UI exists yet, but status changes must be auditable and this feeds a future timeline). Mirrors the verification activity concept.
| Field | Type | Null | Notes |
|-------|------|------|-------|
| `id` | PK | no | |
| `tenant_id` | FK | no | |
| `lead_id` | FK → leads | no | cascade |
| `from_status` | enum | yes | null on create |
| `to_status` | enum | no | |
| `actor_id` | FK → members | yes | |
| `note` | text | yes | |
| `created_at` | timestamptz | no | |
### 4.6 `lead_verifications`
**Purpose:** One row per verification-desk record (the Verification table row + detail).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | indexed |
| `code` | text | no | seq | display code `LD-V-001`; **unique per tenant** |
| `name` | text | no | — | customer full name (single field in UI) |
| `phone` | text | yes | null | |
| `email` | text | yes | null | derived client-side when absent (see `deriveEmail`) |
| `address` | text | yes | null | single-line address string in UI |
| `source` | text | no | — | see `V_SOURCES` (`Door Knock`, `Web Form`, `Storm Canvass`, `Referral`, `Call-In`) |
| `status` | enum `verification_status` | no | `'pending'` | `verified`\|`in_progress`\|`assigned`\|`pending`\|`unverified` |
| `sub_status` | text | no | `'Pending Review'` | the `verification` label, see §8.6 |
| `assignee_id` | FK → members | yes | null | verification specialist |
| `notes` | text | yes | null | "Verification Notes" |
| `promoted_lead_id` | FK → leads | yes | null | set when verified → Lead created |
| `verified_at` | timestamptz | yes | null | |
| `created_at` | timestamptz | no | now() | drives "Created" column |
| `updated_at` | timestamptz | no | now() | |
**Indexes:** `(tenant_id)`, `(tenant_id, status)`, `(tenant_id, source)`, `(tenant_id, assignee_id)`, `(tenant_id, code)` unique, `(tenant_id, created_at desc)`.
**Search:** name + code + phone + source + address → trigram/tsvector.
**Example rows (from `LD-V-001` and a lean one):**
```json
{ "id":"lv_1…","code":"LD-V-001","name":"Kevin Hartley",
"phone":"(972) 413-8902","email":"kevin.hartley@gmail.com",
"address":"2814 Ravenswood Dr, Plano, TX 75023","source":"Door Knock",
"status":"verified","subStatus":"Verified","assigneeId":"…Wade…",
"notes":"Ownership confirmed via county records…",
"verifiedAt":"2026-05-16T19:55:00Z","createdAt":"2026-05-14T14:40:00Z" }
{ "id":"lv_11…","code":"LD-V-011","name":"Aaron Blake",
"phone":"(469) 471-3350","address":"1188 Alma Dr, Plano, TX 75075",
"source":"Door Knock","status":"pending","subStatus":"Pending Review",
"assigneeId":null,"createdAt":"2026-05-27T…" }
```
### 4.7 `lead_verification_activities`
**Purpose:** The Activity timeline in the verification detail (V18). The frontend synthesizes these when absent (`buildActivity`), but the backend should persist real ones.
| Field | Type | Null | Notes |
|-------|------|------|-------|
| `id` | PK | no | |
| `tenant_id` | FK | no | |
| `verification_id` | FK → lead_verifications | no | cascade |
| `text` | text | no | e.g. "Assigned to Wade Hollis." |
| `actor_id` | FK → members | yes | maps to `who` (name shown; "System" when null) |
| `actor_label` | text | yes | denormalized display name / "System" |
| `occurred_at` | timestamptz | no | maps to `time` |
| `kind` | text | yes | optional: `submitted`\|`assigned`\|`in_progress`\|`verified`\|`unverified`\|`note` |
**Index:** `(verification_id, occurred_at)`.
### 4.8 Referenced existing tables (do **not** recreate)
- **`organizations` / tenant** — tenant scope (`tenant_id`). Established at boot / registration (`crm.account.register`).
- **`members`** — CRM team members (reps, canvassers, verification specialists). Already served by `crm.team.member.search` and carry ids like `LUP-1040` plus `principalId`. Leads/verifications reference members for `assigned_to`, `canvasser`, `assignee`, `created_by`, and activity `actor`. In the mock these are free-text names — the backend must resolve them to member ids (see §12).
- **Account/permissions** — `crm.account.me` drives `leads.manage` gating.
---
## 5. Entity Relationships
```
organizations (tenant)
1 ──────────────< leads
1 ──────────────< lead_verifications
1 ──────────────< members
members
1 ──< leads.assigned_to_id
1 ──< leads.canvasser_id
1 ──< leads.created_by_id
1 ──< lead_verifications.assignee_id
1 ──< lead_verification_activities.actor_id
1 ──< lead_status_history.actor_id
leads
1 ──< lead_phones (cascade)
1 ──< lead_emails (cascade)
1 ──< lead_attachments (cascade) [backend-pending]
1 ──< lead_status_history (cascade)
lead_verifications
1 ──< lead_verification_activities (cascade)
1 ──0..1 leads (promotion: lead_verifications.promoted_lead_id
⇆ leads.source_verification_id)
```
**Cardinality summary**
| Relationship | Type | Notes |
|--------------|------|-------|
| tenant → lead | 1tomany | all leads tenant-scoped |
| tenant → verification | 1tomany | |
| lead → phones | 1tomany | ≥1, exactly one primary |
| lead → emails | 1tomany | 0..N |
| lead → attachments | 1tomany | 0..N (pending) |
| lead → status history | 1tomany | audit |
| verification → activities | 1tomany | timeline |
| verification → lead | 1to(0..1) | promotion link, bidirectional FK |
| member → lead (assigned/canvasser/creator) | manyto1 each | nullable |
| member → verification (assignee) | manyto1 | nullable |
**Embedded value objects (not separate tables — stored as columns on `leads`):** `storm { zone, date, detail }`, `property { … }`, `job { … }`, `insurance { … }`, `assignment { … }`. They are grouped only for UI sectioning; there is no reuse that justifies separate tables. Phones and emails **are** separate tables because they are 1tomany.
---
## 6. API Contract — Actions (Queries & Commands)
> **Transport & style (mandatory):** These are **not REST routes**. They are `be-crm` **data-door actions** invoked through the AppShell SDK exactly like every other module:
> - **Reads:** `useQuery<T>("<action>", variables)` → resolves to `T` (or `null` while loading). Errors surface as an `Error` with `.message`.
> - **Writes:** `sdk.command<T>("<action>", variables)` → resolves to `T`. Commands carry an auto-generated idempotency key.
> - **Action naming:** `crm.<entity>.<verb>` — dot-namespaced, camelCase segments (matches `crm.team.member.setRoles`, `crm.inbox.transition`, `crm.mail.reply`).
> - **Collection reads** return either a bare array (like `crm.mail.list`, `crm.inbox.list`) or `{ items: T[], meta }` (like `crm.team.member.search`). For Leads/Verification use **`{ items, meta }`** because the UI needs totals + pagination (§6.1).
### 6.1 Standard collection envelope
```ts
interface Meta { total: number; page: number; perPage: number; }
interface Page<T> { items: T[]; meta: Meta; }
```
- `verify.tsx` renders `"{rows.length} of {V_LEADS.length} leads"``rows.length` is the filtered page length, `meta.total` is the (filtered or overall) count. Return the **filtered** total so the "X of Y" reads correctly, plus the unfiltered stat counts via the dedicated `*.stats` action.
- `leads.tsx` shows `TOTAL_LEADS = 35` while only rendering 5 cards → the board is paginated/capped server-side; provide `perPage` (UI has no pager control yet, so default a sensible `perPage`, e.g. 50, and return `meta.total` for the header).
### 6.2 Leads — Queries
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.lead.search` | `{ query?, status?, priority?, source?, assigneeId?, page?, perPage? }` | `Page<LeadCardDTO>` | board list L1, search L3, tabs L4 |
| `crm.lead.get` | `{ id }` | `LeadDTO` (full detail incl. phones/emails/attachments) | detail modal L5 |
| `crm.lead.stats` | `{}` | `{ total: number, byStatus: { new, contacted, appointed, closed } }` | stat strip L2 |
`LeadCardDTO` (list projection — only what the card needs):
```ts
{ id, code, name, initials, gradient, priority, status, tag,
primaryPhone, propertyAddress, propertyCity, propertyState,
source, canvasserName, updatedAt }
```
`LeadDTO` (detail projection): all §4.1 columns + `phones: Phone[]` + `emails: Email[]` + `attachments: Attachment[]` + resolved assignee/canvasser/creator display names + `storm`, `property`, `job`, `insurance`, `assignment` groupings.
### 6.3 Leads — Commands
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.lead.create` | full create payload (below) | `LeadDTO` | Create Lead L6/L7 |
| `crm.lead.update` | `{ id, patch: Partial<LeadInput> }` | `LeadDTO` | edit (backend-pending; UI edit form not built) |
| `crm.lead.updateStatus` | `{ id, status, note? }` | `LeadDTO` | detail "Update Status" L16 |
| `crm.lead.assign` | `{ id, assigneeId \| null }` | `LeadDTO` | rep assignment L15 |
| `crm.lead.attachment.add` | `{ id, attachment: { contentRef, mimeType, sizeBytes, filename } }` | `LeadDTO` | site photos L10 (pending) |
| `crm.lead.attachment.remove` | `{ id, attachmentId }` | `LeadDTO` | pending |
| `crm.lead.archive` | `{ id }` | `{ id }` | **pending** — no UI yet; include only if requested |
| `crm.lead.delete` | `{ id }` | `{ id }` | **pending** — no UI yet; include only if requested |
**`crm.lead.create` payload** (union of Quick + Full form, all optional except `firstName`/`lastName` where at least one non-empty):
```ts
{
firstName: string, lastName?: string,
phones: { number: string, type: "Mobile"|"Home"|"Work", primary?: boolean }[],
emails?: { address: string, primary?: boolean }[],
property?: { address?, city?, state?, zip?, type? },
photos?: { contentRef, mimeType, sizeBytes, filename }[], // real uploads (pending)
job?: { source?, referralNote?, canvasserId?, leadType?, workType?, tradeType?,
urgency?: "Standard"|"High"|"Emergency", notes? },
insurance?: { company?, claimNumber?, claimStatus?, adjusterName?,
adjusterPhone?, policyNumber? },
assignment?: { assigneeId?: string|null, priority?: "Low"|"Medium"|"High",
followUp?: string /* ISO date */ }
}
```
> Note the **priority casing mismatch**: the create form emits `"Low"|"Medium"|"High"` (title-case) while list/detail data uses `"low"|"medium"|"high"`. Normalize to lowercase on write (see §12).
### 6.4 Lead Verification — Queries
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.leadVerification.search` | `{ query?, status?, source?, assigneeId?, page?, perPage? }` | `Page<VerificationRowDTO>` | table V3, search V4, filters V5V7 |
| `crm.leadVerification.get` | `{ id }` | `VerificationDTO` (+ activities) | detail V16V18 |
| `crm.leadVerification.stats` | `{}` | `{ verified, in_progress, assigned, pending, unverified }` | stat tiles V1 |
| `crm.leadVerification.activity.list` | `{ id }` | `VerificationActivity[]` | timeline V18 (or embed in `.get`) |
`VerificationRowDTO`:
```ts
{ id, code, name, initials, address, phone, source,
assignee: { id, initials, name } | null,
status, verification /* sub_status */, createdAt }
```
`VerificationDTO`: adds `email`, `notes`, `verifiedAt`, `promotedLeadId`, `activities[]`.
### 6.5 Lead Verification — Commands
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.leadVerification.verify` | `{ id, notes? }` | `{ verification: VerificationDTO, lead: LeadDTO }` | Verify V9/V11/V19 — **promotes to Lead** |
| `crm.leadVerification.markUnverified` | `{ id, reason? }` | `VerificationDTO` | Mark Unverified V12 |
| `crm.leadVerification.assign` | `{ id, assigneeId }` | `VerificationDTO` | Change Assignee V13 |
| `crm.leadVerification.reassign` | `{ id, assigneeId? }` | `VerificationDTO` | Reassign → In Progress V14 |
| `crm.leadVerification.moveToPending` | `{ id }` | `VerificationDTO` | Move to Pending V15 |
| `crm.leadVerification.updateStatus` | `{ id, status, subStatus? }` | `VerificationDTO` | generic status set (covers sub-status changes) |
| `crm.leadVerification.note.add` | `{ id, text }` | `VerificationDTO` | verification notes (pending edit UI) |
**`crm.leadVerification.verify` behaviour (the critical cross-module command):**
1. Load verification; guard status is not already `verified`.
2. Set `status = 'verified'`, `sub_status = 'Verified'`, `verified_at = now()`, persist `notes` if provided.
3. **Create a `leads` row** (`status = 'new'`) from the verification's contact/address/source, linking `leads.source_verification_id = verification.id` and `verification.promoted_lead_id = lead.id`.
4. Append activity: `"Verified and pushed to New Leads."` (actor = current user).
5. Return both records so the UI can refresh both queues.
6. Idempotent: a second call with the same idempotency key must not create a duplicate Lead.
### 6.6 Shared / reused actions (already exist — reuse, don't rebuild)
| Action | Use in Leads/Verification |
|--------|---------------------------|
| `crm.account.me` | `leads.manage` permission gate for both modules |
| `crm.team.member.search { perPage }` | rep / canvasser / assignee pickers (`REPS`, `V_ASSIGNEES`) |
| `crm.media.presignUpload { mime, sizeBytes }``{ objectKey, uploadUrl }` | site-photo upload (browser PUTs bytes directly) |
| `crm.media.presignDownload { contentRef }``{ url }` | render/download a lead photo |
---
## 7. Validation Rules
### 7.1 `crm.lead.create` / `crm.lead.update`
| Field | Required | Rule |
|-------|----------|------|
| name (`firstName`/`lastName`) | **Yes** (at least one non-empty after trim) | matches the only current UI guard: `` `${first} ${last}`.trim() `` must be non-empty → else error `name_required` |
| `phones[].number` | No | free-form; if present, strip to digits server-side for storage/dedupe (form already does `phone.replace(/\D/g,"")` in registration) |
| `phones[].type` | with phone | enum `Mobile\|Home\|Work` |
| `phones` primary | — | at most one `primary: true`; if none set, mark the first as primary |
| `emails[].address` | No | RFC-5322-ish email format when present |
| `property.state` | No | 2-letter US state; default `TX` |
| `property.zip` | No | 5-digit (or ZIP+4) when present |
| `property.type` | No | one of `PROPERTY_TYPES` (§8.4) |
| `source` | No | one of `LEAD_SOURCES` (§8.1) |
| `referralNote` | Conditional | accept only when `source = 'Referral'` (UI shows it only then) |
| `canvasserId` | Conditional | resolve to a member; UI surfaces the picker only when `source = 'Door Knock'`, but accept whenever provided |
| `leadType` | No | see §12 enum conflict — accept the UI's `Residential\|Commercial\|Multi-Family` **and** the data model's `Insurance\|Retail`; store raw, do not hard-reject |
| `workType` | No | one of `WORK_TYPES` |
| `tradeType` | No | one of `TRADE_TYPES` |
| `urgency` | No | enum `Standard\|High\|Emergency`; default `Standard` |
| `insurance.claimStatus` | No | enum `Not Filed\|Filed\|Approved\|Paid\|Denied` |
| `priority` | No | accept `Low\|Medium\|High` (form) → normalize to `low\|medium\|high`; default `medium` |
| `assignment.followUp` | No | valid ISO date; may be null/`"—"` |
| `assignment.assigneeId` | No | resolve to a member or `null` (Unassigned) |
**Business validations:**
- `code` (`SAL-###`) is server-generated, unique per tenant — never accepted from the client.
- Duplicate detection (recommended, not in UI): warn (not block) if an active lead exists with the same primary phone **or** same property address within the tenant.
- All member references (`assigneeId`, `canvasserId`) must belong to the same tenant.
### 7.2 Verification commands
| Command | Rule |
|---------|------|
| `verify` | reject if already `verified` (`already_verified`); require the record exists in tenant; create exactly one Lead (idempotent) |
| `assign` / `reassign` | `assigneeId` must be a tenant member; `assign` moves `pending → assigned` (or updates assignee); `reassign` sets `in_progress` |
| `markUnverified` | allowed from any non-terminal state; optional `reason` recorded as activity |
| `moveToPending` | allowed from `assigned` / `in_progress`; clears/keeps assignee per product choice (UI does not specify — keep assignee, set `status=pending`, `sub_status='Pending Review'`) |
| `updateStatus` | `status ∈ verification_status`; if `subStatus` omitted, default it from the status (§8.6 mapping) |
| `note.add` | `text` non-empty, trimmed, reasonable max length (e.g. 5 000 chars) |
**Enums must be validated server-side even though the frontend does not enforce them** — the frontend is permissive (free selects), so the backend is the source of truth.
---
## 8. Reference / Enum Data
Exact values, copied from `leads-data.ts` and `verify-data.ts`. Provide these either as DB enums or as a reference-data query (`crm.lead.options` — optional) so the UI selects stay in sync.
### 8.1 `LEAD_SOURCES`
`Door Knock`, `Referral`, `Storm Chase`, `Mailer / Postcard`, `Sign Call`, `Insurance Agent Referral`, `Repeat Customer`, `Social Media`, `Other`
### 8.2 `WORK_TYPES`
`Roof Replacement`, `Roof Repair`, `Inspection`, `Gutter Install`
### 8.3 `TRADE_TYPES`
`Roofing`, `Gutters`, `Siding`, `Windows`
### 8.4 `PROPERTY_TYPES`
`Single Family`, `Multi Family`, `Commercial`
*(the Full-form Property step also offers `Residential`, `Commercial`, `Multi-Family`, `Industrial` via `PROPERTY_TYPE_OPTS` — accept the superset; see §12)*
### 8.5 `CLAIM_STATUSES`
`Not Filed`, `Filed`, `Approved`, `Paid`, `Denied`
### 8.6 Verification statuses → default sub-status label
| `status` | default `sub_status` | other observed sub-status labels |
|----------|----------------------|----------------------------------|
| `verified` | `Verified` | — |
| `in_progress` | `Verifying Identity` | `Reviewing Insurance`, `Confirming Ownership`, `Confirming Damage` |
| `assigned` | `Assigned` | — |
| `pending` | `Pending Review` | — |
| `unverified` | `Unverified` | — |
### 8.7 Verification sources (`V_SOURCES`)
`Door Knock`, `Web Form`, `Storm Canvass`, `Referral`, `Call-In`
*(note: different set from Lead sources §8.1 — keep them as separate reference lists)*
### 8.8 Lead statuses / priorities / urgency
- `lead_status`: `new`, `contacted`, `appointed`, `closed`
- `lead_priority`: `high`, `medium`, `low` (create form emits title-case `Low`/`Medium`/`High`)
- `lead_urgency`: `Standard`, `High`, `Emergency`
- `phone_type`: `Mobile`, `Home`, `Work`
- `lead_type`: **conflicting** — `Insurance`/`Retail` (data) vs `Residential`/`Commercial`/`Multi-Family` (form). See §12.
### 8.9 Seed members (reps / canvassers / verification specialists)
Reps (`REPS`, ids are real member codes): `LUP-1040 Cody Tatum`, `LUP-1041 Hannah Reyes`, `LUP-1042 Travis Boone`, `LUP-1043 Shelby Greer`, `LUP-1044 Dalton Pruitt`.
Verification specialists (`V_ASSIGNEES`): `Wade Hollis`, `Darlene Brooks`, `Roy Schaefer`.
---
## 9. Backend Services
Break the implementation into services mirroring the existing `be-crm` module layout (one bounded context per data-door namespace).
| Service | Namespace | Responsibilities |
|---------|-----------|------------------|
| **LeadService** | `crm.lead.*` | CRUD + search + stats for leads; owns phones/emails child collections; code generation (`SAL-###`); status transitions; assignment. |
| **LeadVerificationService** | `crm.leadVerification.*` | Verification queue search/stats; status & sub-status transitions; assign/reassign/pending; note management; code generation (`LD-V-###`). |
| **LeadPromotionService** (or a method on LeadVerificationService) | part of `crm.leadVerification.verify` | The cross-module promotion: verified verification → new Lead, bidirectional linking, idempotency. Owns the transaction that touches both `leads` and `lead_verifications`. |
| **LeadActivityService** | activities/history | Append + list `lead_verification_activities` and `lead_status_history`; generates the timeline the UI reads (replaces the client-side `buildActivity` synthesis). |
| **LeadAssignmentService** | assignment concerns | Resolve member ids for `assignedTo` / `canvasser` / `assignee`; validate tenant membership; (future) round-robin. Can be a thin helper over the existing member/team module rather than a standalone service. |
| **MediaService (existing — reuse)** | `crm.media.*` | Presign upload/download for site photos. **Do not reimplement** — see media-api.ts. |
| **Account/AccessService (existing — reuse)** | `crm.account.me` | `leads.manage` authorization for every action here. |
| **NotificationService** — `[Backend Pending]` | — | The frontend today only toasts locally. No server notification is required for parity, but promotion/verify events are natural triggers if/when the Inbox projection (`crm.inbox.*`) should surface them. Do not build unless requested. |
**Layering convention (match existing modules):** Controller/handler (validates action variables, applies auth) → Service (business rules, transitions) → Repository (tenant-scoped data access). DTO projections are shaped for the UI (list vs detail) exactly as the current modules return purpose-built DTOs (e.g. `MemberDTO`, `MailThread`).
---
## 10. Error Handling
Errors propagate to the UI as a thrown `Error`; the SDK hooks expose `error.message` (see `q.error?.message ?? null` throughout the API layer). Permission failures use the SDK's `PermissionError` (OPA). Provide a **stable machine `code` + human `message`**; the UI currently shows the message in a toast/inline state.
| Scenario | When | Suggested code | HTTP-equivalent | UI effect |
|----------|------|----------------|-----------------|-----------|
| Unauthenticated | no/expired session | `unauthenticated` | 401 | `auth-gate` redirects to `/portal/login` |
| Forbidden | member lacks `leads.manage` | `forbidden` / `PermissionError` | 403 | nav item hidden; action rejected |
| Validation — name required | create with empty name | `name_required` | 422 | matches current client guard toast |
| Validation — bad enum | invalid status/source/type | `invalid_value` | 422 | inline/toast |
| Validation — bad email/zip/phone | format fails | `invalid_format` | 422 | |
| Not found | unknown `id` / wrong tenant | `not_found` | 404 | toast; row disappears on refetch |
| Conflict — already verified | `verify` on a verified record | `already_verified` | 409 | toast |
| Conflict — invalid transition | disallowed status change | `invalid_state_transition` | 409 | toast |
| Conflict — duplicate lead | dup phone/address (if enforced) | `duplicate_lead` | 409 | warn (prefer soft-warn, not block) |
| Conflict — duplicate code | code collision (should be internal) | `duplicate_code` | 409 | internal retry |
| Assignee invalid | assignee not a tenant member | `invalid_assignee` | 422 | toast |
| Upload too large | photo > 25 MB | `file_too_large` | 413 | matches media-api guard ("max 25 MB") |
| Upload failed | presigned PUT fails | `upload_failed` | 502 | toast |
| Rate / idempotency replay | duplicate command key | (return original result) | 200 | no-op, safe |
| Server error | unexpected | `internal_error` | 500 | generic toast |
**Error envelope (recommended, consistent with a thrown Error carrying structured data):**
```json
{ "error": { "code": "already_verified",
"message": "This lead has already been verified.",
"details": { "verificationId": "lv_1…" } } }
```
---
## 11. Existing Backend Integration (mandatory conventions)
The new backend **must** follow the patterns already used by Team, Mail, Inbox, Messenger, Media and Account. Never invent a different architecture.
### 11.1 Data door, not REST
The frontend never calls REST endpoints. It calls **actions** through the AppShell SDK:
```ts
// read
const q = useQuery<Page<LeadCardDTO>>("crm.lead.search", { status, query, page, perPage });
// write
await sdk.command("crm.leadVerification.verify", { id, notes });
```
`DataClient.query(action, variables)` and `DataClient.command(action, variables)` call the **Shell BFF's cookie-authed `/data` proxy**; the BFF attaches the session token server-side and forwards to `be-crm`. Apps never hold a token, set an `Authorization` header, or know the domain endpoint.
### 11.2 Transport path
```
browser (SDK) ──► Next rewrite /shell/:path* ──► BFF (BFF_ORIGIN, default http://localhost:4000)/api/:path* ──► be-crm domain API
```
(from `next.config.ts` `rewrites()` and `providers.tsx` `bffBaseUrl = "/shell"`). The session is an **HttpOnly cookie**; that is why the same-origin `/shell` prefix is used (never `/api`, to avoid clobbering the local `/api/geo` route).
### 11.3 Authentication & authorization
- Auth is Supabase-backed via the SDK (`appId: "crm-web"`); the BFF exchanges it for a domain session. When `NEXT_PUBLIC_SUPABASE_URL` is unset the app runs in **mock mode** (see §11.6).
- Authorization is **permission-based** via `crm.account.me` → `{ registered, isMember, roleSlugs, permissions, isSuperadmin }`. Both modules require **`leads.manage`** (already declared in `access.ts` `ALL_CRM_PERMISSIONS` and mapped in `sidebar.tsx` `NAV_PERMISSION`). Enforce it on **every** `crm.lead.*` and `crm.leadVerification.*` action — the frontend gate is UX-only ("be-crm still enforces every action").
- OPA/IIOS may additionally gate row visibility; keep everything **tenant-scoped**.
### 11.4 Response & naming conventions
- **Actions:** `crm.<entity>.<verb>` dot-namespaced; segments camelCase (e.g. `member.setRoles`, `invitation.create`, `inbox.transition`, `mail.reply`). Use `search`/`list` for reads, imperative verbs for writes.
- **DTOs:** server returns UI-shaped DTOs (list vs detail projections). Fields are **camelCase**. Timestamps are **ISO-8601 strings**; the client formats relative/pretty (`relTime`). Nullable fields are `null`, not omitted, where the UI reads them.
- **Collections:** `{ items: T[], meta: { total, page, perPage } }` for paginated sets (Team pattern) — used here for `search`. Bare arrays are acceptable for small always-full lists (Mail/Inbox pattern) but Leads/Verification use the envelope for totals.
- **Commands** are idempotent (idempotency key auto-attached); write-then-refetch is the client norm (`cmd(...)` then `refetch()`), so commands should return the updated entity to allow optimistic UI too.
### 11.5 Media / attachments (reuse exactly)
Site photos must use the existing two-step flow (from `media-api.ts`):
1. `sdk.command("crm.media.presignUpload", { mime, sizeBytes })` → `{ objectKey, uploadUrl }`.
2. Browser `PUT`s the file bytes **directly** to `uploadUrl` (IIOS storage) — be-crm only mints the URL.
3. Store `{ contentRef: objectKey, mimeType, sizeBytes, filename }` on the lead via `crm.lead.attachment.add`.
4. Display via `crm.media.presignDownload { contentRef }` → `{ url }`.
Enforce the **25 MB** cap (`MAX_ATTACHMENT_BYTES = 26_214_400`).
### 11.6 Mock-mode parity (important)
Every existing data-layer file (`team-api`, `mail-api`, `inbox-api`, `messenger-api`, `access`) chooses **mock vs live at module load** via `isShellConfigured()` (`Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL)`). When you wire Leads/Verification to the live door, follow the same shape: create `src/lib/leads-api.ts` and `src/lib/verify-api.ts` exposing one hook per module that returns `{ live, loading, error, …data, …commands, refetch }`, serving the current mock (`LEADS`, `V_LEADS`) when the Shell isn't configured and the live `crm.lead.*` / `crm.leadVerification.*` actions when it is. This keeps the demo working and matches the established convention. *(Frontend wiring task — noted for completeness; the backend itself only needs to implement the live actions.)*
### 11.7 DTO / repository / service pattern summary
- **DTO:** dedicated interfaces per projection (`LeadCardDTO`, `LeadDTO`, `VerificationRowDTO`, `VerificationDTO`) — do not leak raw table rows.
- **Repository:** always parameterize by `tenantId`; never a query without tenant scope.
- **Service:** owns transitions, code generation, promotion transaction, activity emission.
- **Controller/handler:** maps the action name + variables to a service call, validates, applies auth. One handler per action, matching the `crm.<entity>.<verb>` registry the BFF forwards to.
---
## 12. Gaps, Discrepancies & Backend-Pending Items
Explicitly flagged so the backend developer is not surprised.
1. **Everything is mock today.** No `crm.lead.*` or `crm.leadVerification.*` action is called anywhere yet. All actions in §6 are new. Frontend wiring (§11.6) is a separate follow-up task.
2. **`leadType` enum conflict.** `leads-data.ts` defines `LEAD_TYPES = ["Insurance","Retail"]` and mock rows use `"Insurance"`, but the Full-form Job step's `LEAD_TYPE_OPTS = ["Residential","Commercial","Multi-Family"]`. **Recommendation:** store `lead_type` as free text (do not hard-reject), accept both sets, and raise with product which taxonomy is canonical. **[Needs product decision]**
3. **`propertyType` superset.** `PROPERTY_TYPES` (data) = `Single Family / Multi Family / Commercial`; the form offers `Residential / Commercial / Multi-Family / Industrial`. Accept the union.
4. **Priority casing.** Create form emits `Low/Medium/High`; list/detail use `low/medium/high`. **Normalize to lowercase** on write; return lowercase.
5. **Assignee/canvasser/creator are free-text names in the mock** (`"Jesus Gonzales"`, `"Cody Tatum"`, `"Wade Hollis"`). The backend must model these as **member FKs** and resolve display names in DTOs. The verification specialists (`Wade Hollis`, `Darlene Brooks`, `Roy Schaefer`) are not in the `REPS` list — they must exist as members/records too. **[Seed/link required]**
6. **Site photos are placeholders.** `addPhoto()` stores label strings (`"Photo 1"`). Real upload via the media presign flow (§11.5) is **backend-pending** but fully specified.
7. **Detail actions are inert.** Leads "Call / Email / Update Status" and Verification "Call / Verify" footer buttons have **no handlers**. `updateStatus`, `verify` etc. are specified in §6 but the buttons must be wired (frontend task).
8. **Activity timeline is client-synthesized.** `buildActivity()` fabricates timeline entries when a verification lacks `activity[]`. The backend should persist **real** activities (`lead_verification_activities`) and the client should stop synthesizing once live.
9. **No pagination controls in the UI**, yet `TOTAL_LEADS = 35` (only 5 loaded) and the verification footer shows "X of Y". Implement server pagination + `meta.total`; pick a sane default `perPage`. A pager UI is a future frontend task.
10. **No edit / delete / archive / bulk / tags-editor / sort UI.** Endpoints for update/archive/delete are included as **optional/pending** — implement `crm.lead.update` (needed for status/assignment) but treat `archive`/`delete`/bulk as **do-not-build-unless-requested**.
11. **Verification records are never created from the UI** (like the Inbox, they "arrive"). Provide an intake ingestion path (webhook / internal command) **outside** these two module screens — its exact source is not defined by the frontend. **[Needs definition]**
12. **Single-line vs structured address.** Leads store structured address (address/city/state/zip); verifications store a single `address` string (`"2814 Ravenswood Dr, Plano, TX 75023"`). When promoting a verification → lead, parse or carry the single string into `property_address` (best-effort parse; keep the raw string too). **[Parsing rule needed]**
---
*End of document.*