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

54 KiB
Raw Blame History

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
  2. Existing Frontend Features (exhaustive)
  3. User Flows
  4. Database Models
  5. Entity Relationships
  6. API Contract — Actions (Queries & Commands)
  7. Validation Rules
  8. Reference / Enum Data
  9. Backend Services
  10. Error Handling
  11. Existing Backend Integration (mandatory conventions)
  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 IdentityConfirming OwnershipReviewing InsuranceConfirming 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: ContactedAppointedClosed, 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 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):

{
  "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()

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 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/permissionscrm.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

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):

{ 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 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:

{ 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: conflictingInsurance/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):

{ "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. 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 PUTs 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.