54 KiB
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, orverify-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-crmarchitecture (query/command data door, not REST). Do not invent a REST API — see §11 (Existing Backend Integration) for the mandatory conventions.
Table of Contents
- Module Overview
- Existing Frontend Features (exhaustive)
- User Flows
- Database Models
- Entity Relationships
- API Contract — Actions (Queries & Commands)
- Validation Rules
- Reference / Enum Data
- Backend Services
- Error Handling
- Existing Backend Integration (mandatory conventions)
- 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 (seecrm.leadVerification.verifyin §6). - Both modules are gated by the same CRM permission:
leads.manage(seeNAV_PERMISSIONinsidebar.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)
- A lead is captured at an intake source and lands in the Verification queue as
pending(unassigned) orassigned. - 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). - The specialist either Verifies it (→ becomes a Lead,
status = new) or Marks Unverified. - 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 LeadCards |
| 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
verifiedrecord 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):
{
"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):
{ "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 bycrm.team.member.searchand carry ids likeLUP-1040plusprincipalId. Leads/verifications reference members forassigned_to,canvasser,assignee,created_by, and activityactor. In the mock these are free-text names — the backend must resolve them to member ids (see §12).- Account/permissions —
crm.account.medrivesleads.managegating.
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 | 1‑to‑many | all leads tenant-scoped |
| tenant → verification | 1‑to‑many | |
| lead → phones | 1‑to‑many | ≥1, exactly one primary |
| lead → emails | 1‑to‑many | 0..N |
| lead → attachments | 1‑to‑many | 0..N (pending) |
| lead → status history | 1‑to‑many | audit |
| verification → activities | 1‑to‑many | timeline |
| verification → lead | 1‑to‑(0..1) | promotion link, bidirectional FK |
| member → lead (assigned/canvasser/creator) | many‑to‑1 each | nullable |
| member → verification (assignee) | many‑to‑1 | 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 1‑to‑many.
6. API Contract — Actions (Queries & Commands)
Transport & style (mandatory): These are not REST routes. They are
be-crmdata-door actions invoked through the AppShell SDK exactly like every other module:
- Reads:
useQuery<T>("<action>", variables)→ resolves toT(ornullwhile loading). Errors surface as anErrorwith.message.- Writes:
sdk.command<T>("<action>", variables)→ resolves toT. Commands carry an auto-generated idempotency key.- Action naming:
crm.<entity>.<verb>— dot-namespaced, camelCase segments (matchescrm.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 }(likecrm.team.member.search). For Leads/Verification use{ items, meta }because the UI needs totals + pagination (§6.1).
6.1 Standard collection envelope
interface Meta { total: number; page: number; perPage: number; }
interface Page<T> { items: T[]; meta: Meta; }
verify.tsxrenders"{rows.length} of {V_LEADS.length} leads"→rows.lengthis the filtered page length,meta.totalis the (filtered or overall) count. Return the filtered total so the "X of Y" reads correctly, plus the unfiltered stat counts via the dedicated*.statsaction.leads.tsxshowsTOTAL_LEADS = 35while only rendering 5 cards → the board is paginated/capped server-side; provideperPage(UI has no pager control yet, so default a sensibleperPage, e.g. 50, and returnmeta.totalfor 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):
{ 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):
{
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 V5–V7 |
crm.leadVerification.get |
{ id } |
VerificationDTO (+ activities) |
detail V16–V18 |
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:
{ 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):
- Load verification; guard status is not already
verified. - Set
status = 'verified',sub_status = 'Verified',verified_at = now(), persistnotesif provided. - Create a
leadsrow (status = 'new') from the verification's contact/address/source, linkingleads.source_verification_id = verification.idandverification.promoted_lead_id = lead.id. - Append activity:
"Verified and pushed to New Leads."(actor = current user). - Return both records so the UI can refresh both queues.
- 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,closedlead_priority:high,medium,low(create form emits title-caseLow/Medium/High)lead_urgency:Standard,High,Emergencyphone_type:Mobile,Home,Worklead_type: conflicting —Insurance/Retail(data) vsResidential/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):
{ "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:
// 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. WhenNEXT_PUBLIC_SUPABASE_URLis 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 requireleads.manage(already declared inaccess.tsALL_CRM_PERMISSIONSand mapped insidebar.tsxNAV_PERMISSION). Enforce it on everycrm.lead.*andcrm.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). Usesearch/listfor 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 arenull, not omitted, where the UI reads them. - Collections:
{ items: T[], meta: { total, page, perPage } }for paginated sets (Team pattern) — used here forsearch. 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(...)thenrefetch()), 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):
sdk.command("crm.media.presignUpload", { mime, sizeBytes })→{ objectKey, uploadUrl }.- Browser
PUTs the file bytes directly touploadUrl(IIOS storage) — be-crm only mints the URL. - Store
{ contentRef: objectKey, mimeType, sizeBytes, filename }on the lead viacrm.lead.attachment.add. - 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.
-
Everything is mock today. No
crm.lead.*orcrm.leadVerification.*action is called anywhere yet. All actions in §6 are new. Frontend wiring (§11.6) is a separate follow-up task. -
leadTypeenum conflict.leads-data.tsdefinesLEAD_TYPES = ["Insurance","Retail"]and mock rows use"Insurance", but the Full-form Job step'sLEAD_TYPE_OPTS = ["Residential","Commercial","Multi-Family"]. Recommendation: storelead_typeas free text (do not hard-reject), accept both sets, and raise with product which taxonomy is canonical. [Needs product decision] -
propertyTypesuperset.PROPERTY_TYPES(data) =Single Family / Multi Family / Commercial; the form offersResidential / Commercial / Multi-Family / Industrial. Accept the union. -
Priority casing. Create form emits
Low/Medium/High; list/detail uselow/medium/high. Normalize to lowercase on write; return lowercase. -
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 theREPSlist — they must exist as members/records too. [Seed/link required] -
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. -
Detail actions are inert. Leads "Call / Email / Update Status" and Verification "Call / Verify" footer buttons have no handlers.
updateStatus,verifyetc. are specified in §6 but the buttons must be wired (frontend task). -
Activity timeline is client-synthesized.
buildActivity()fabricates timeline entries when a verification lacksactivity[]. The backend should persist real activities (lead_verification_activities) and the client should stop synthesizing once live. -
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 defaultperPage. A pager UI is a future frontend task. -
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 treatarchive/delete/bulk as do-not-build-unless-requested. -
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]
-
Single-line vs structured address. Leads store structured address (address/city/state/zip); verifications store a single
addressstring ("2814 Ravenswood Dr, Plano, TX 75023"). When promoting a verification → lead, parse or carry the single string intoproperty_address(best-effort parse; keep the raw string too). [Parsing rule needed]
End of document.