From fd2e02086edbe2dcd0461232aa99cc3abc2725e7 Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Fri, 24 Jul 2026 15:20:29 +0530 Subject: [PATCH] feat(leads+verification): wire Leads & Lead Verification to be-crm data door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates the Leads and Lead Verification screens with the live be-crm backend (crm.lead.* / crm.leadVerification.* / crm.media.*) behind a mode-agnostic data layer that still falls back to the local mock when the Shell isn't configured. Data layer (new) - src/lib/leads-api.ts — crm.lead.search/get/stats/create/update/ updateStatus/assign/sendForVerification + media (presign upload/download) - src/lib/verify-api.ts — crm.leadVerification.search/get/stats/verify/ markUnverified/assign/reassign/moveToPending Backend → FE wiring - Assignee / creator names: read the resolved DTO fields and, as a safety net, resolve member ids → real names against crm.team.member.search (be-crm currently echoes the raw principalId as the name). - Created By: read the createdBy object the backend returns (was blank). - Site photos: upload via crm.media.presignUpload → PUT, persist through crm.lead.attachment.add (per photo, after create), render in the detail popup via crm.media.presignDownload. - Duplicate guard: surface the backend's real error (detail / duplicate_lead) instead of the SDK's opaque "data command failed: 400". UX - Leads detail: Property Photos gallery + edit-mode photo add/remove. - Verification: "Change Assignee" now opens a searchable, scrollable popup instead of a long inline dropdown. - Removed the static "Storm Zone" tag from lead cards/detail; storm banner only renders when the lead carries storm data. Reference / follow-ups - leads.http, leads.postman_collection.json — be-crm data-door requests. - LEADS_BACKEND_CHANGES.md — required be-crm changes (name resolution, assignee carry-over on sendForVerification) verified against :4010. Co-Authored-By: Claude Opus 4.8 (1M context) --- LEADS_BACKEND_CHANGES.md | 99 ++++ leads.http | 340 +++++++++++++ leads.postman_collection.json | 554 +++++++++++++++++++++ src/app/dashboard/dashboard.css | 30 ++ src/components/dashboard/leads-data.ts | 12 +- src/components/dashboard/leads.tsx | 625 +++++++++++++++++++----- src/components/dashboard/verify-data.ts | 5 +- src/components/dashboard/verify.tsx | 201 ++++++-- src/lib/leads-api.ts | 568 +++++++++++++++++++++ src/lib/verify-api.ts | 276 +++++++++++ 10 files changed, 2539 insertions(+), 171 deletions(-) create mode 100644 LEADS_BACKEND_CHANGES.md create mode 100644 leads.http create mode 100644 leads.postman_collection.json create mode 100644 src/lib/leads-api.ts create mode 100644 src/lib/verify-api.ts diff --git a/LEADS_BACKEND_CHANGES.md b/LEADS_BACKEND_CHANGES.md new file mode 100644 index 0000000..d3c0a38 --- /dev/null +++ b/LEADS_BACKEND_CHANGES.md @@ -0,0 +1,99 @@ +# Leads — Backend Changes Required (be-crm) + +Follow-ups the **frontend now depends on**. The frontend side of each item below is +already implemented and shipped on branch `integration-lead-and-verification-be`; these +are the matching changes needed in **be-crm** (`crm.lead.*` / `crm.media.*`) for the +features to work end-to-end. + +Companion to `LEADS_BACKEND_REQUIREMENTS.md` (the full contract). Section refs (§) point there. + +**How this was found:** integration testing the live data door at `http://localhost:4010` +(trust-headers dev mode: `X-Tenant-ID: tnt_demo`, `X-Principal-ID: prn_owner`). + +--- + +## 1. Resolve assignee / creator IDs to member display names in `LeadDTO` + +**Status:** IDs are stored correctly; **display names are not resolved.** + +### What we observed +`crm.lead.create` with `assignment.assigneeId` → the id persists, and `crm.lead.get` +returns the assignee/creator — but the `name` is just the **raw principal id echoed back**, +and the dedicated `*Name` fields come back empty: + +```jsonc +// crm.lead.get response (relevant fields) +"assignedToId": "prn_owner", +"assignedToName": "", // ← empty, not resolved +"assignee": { "id": "prn_owner", "name": "prn_owner", "initials": "P" }, // name = the id +"createdById": "prn_owner", +"createdByName": "", // ← empty, not resolved +"createdBy": { "id": "prn_owner", "name": "prn_owner", "initials": "P" } // name = the id +``` + +The contract (§6.2) says the detail projection must carry **"resolved assignee / canvasser / +creator display names."** Today they are unresolved, so the detail popup's **Assigned To** and +**Created By** show a raw id (or nothing) instead of a person's name. + +### Required change +In the `crm.lead.get` (and any `LeadDTO`-returning command) projection, **join the member +FKs to the `members` table** and populate the real `displayName`: + +- `assigned_to_id` → `assignedToName` **and** `assignee.name` = member `displayName` +- `created_by_id` → `createdByName` **and** `createdBy.name` = member `displayName` +- `canvasser_id` → `canvasserName` (same treatment, §5 note 5) + +Fall back to the id only when the member can't be resolved. Note the dev tenant `tnt_demo` +returns **0 members** from `crm.team.member.search` — seed members there so assignment can be +exercised (§12 note 5 / "[Seed/link required]"). + +### Frontend interim (already shipped — remove once backend resolves names) +`src/lib/leads-api.ts` now resolves the returned id against the local reps list +(`crm.team.member.search`, principalId → name) as a safety net, and reads the `createdBy` +**object** (previously the reader only looked at the never-populated `createdByName`, so +"Created By" rendered blank). Backend resolution is still the correct long-term source of truth. + +--- + +## 2. Site photos — NO backend change (uses existing `crm.lead.attachment.add`) + +**Status:** No new backend work. The endpoints already exist; the frontend was wired to them. + +### Decision: photos persist via `crm.lead.attachment.add` AFTER create (NOT in `crm.lead.create`) +Photos are **not** sent in the create payload. The frontend creates the lead first, then calls the +existing `crm.lead.attachment.add` command once per photo, keyed by the new lead's id (postman **#24**). + +### Frontend flow (already shipped) +1. User picks images in the full form → Property step. +2. Each file is uploaded to storage via the shared Media module: + `crm.media.presignUpload { mime, sizeBytes }` → `{ objectKey, uploadUrl }`, then browser `PUT`s the bytes (§11.5). +3. On submit: `crm.lead.create` (WITHOUT photos) → then per photo: + ```jsonc + { "action": "crm.lead.attachment.add", + "variables": { "id": "", + "attachment": { "contentRef": "", "mimeType": "image/jpeg", "sizeBytes": 12345, "filename": "roof.jpg" } } } + ``` +4. Detail popup reads `attachments[]` from `crm.lead.get` and renders each via `crm.media.presignDownload { contentRef }`. + +### Backend expectations (should already hold — just confirm) +- `crm.lead.attachment.add` inserts a `lead_attachments` row and enforces the 25 MB cap → `413 file_too_large` (§4.4). +- `crm.lead.get` returns the stored photos as **`attachments: Attachment[]`**, each with at least + `{ id, contentRef, mimeType, sizeBytes, filename }` (postman #3 / #24 already reference `j.attachments`). +- `crm.media.presignUpload` / `crm.media.presignDownload` are registered for this app/tenant (shared Media module). + +### Not verified live +Could **not** confirm against `:4010` (dev backend intermittently down during testing). When stable, run: +`crm.media.presignUpload` → `crm.lead.create` → `crm.lead.attachment.add` → `crm.lead.get` and confirm +`attachments[]` comes back. + +--- + +## Quick verification checklist (run against `:4010` when backend is up) + +- [ ] Seed members in `tnt_demo`; `crm.team.member.search` returns > 0 items. +- [ ] `crm.lead.create` with `assignment.assigneeId = ` → `crm.lead.get` + returns `assignedToName` = that member's real display name. +- [ ] `crm.lead.get` returns `createdByName` = the creating caller's display name. +- [ ] `crm.media.presignUpload` returns `{ objectKey, uploadUrl }`; PUT to `uploadUrl` succeeds. +- [ ] `crm.lead.attachment.add { id, attachment }` → `crm.lead.get` returns matching `attachments[]`. +- [ ] `crm.media.presignDownload { contentRef }` returns a working signed URL for a stored photo. diff --git a/leads.http b/leads.http new file mode 100644 index 0000000..a59e983 --- /dev/null +++ b/leads.http @@ -0,0 +1,340 @@ +############################################################################### +# Leads & Lead Verification — data-door requests (VS Code "REST Client" / JetBrains HTTP). +# Install the REST Client extension, then click "Send Request" above each block. +# Requests are chained: run the "@name" reads/writes in order so the id variables +# below resolve. The cleanest end-to-end path is: +# #10 intake → #11 assign → #12 reassign → #15 verify (promotes to a Lead). +# Dev trust-headers mode — change @principal to test different callers. +############################################################################### + +@baseUrl = http://localhost:4010 +@tenant = tnt_demo +@principal = prn_owner + +# Shared headers are repeated per request (the format has no header includes). + +### Health — see every registered action (crm.lead.* + crm.leadVerification.*) +GET {{baseUrl}}/health + +# ─────────────────────────────── LEADS: READS (POST /query) ───────────────────── + +### 1. Lead stats (header stat strip: total + byStatus) +POST {{baseUrl}}/query +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +Content-Type: application/json + +{ "action": "crm.lead.stats", "variables": {} } + +### 2. Lead search (RUN THIS — provides @leadId). Supports query/status/priority/source/assigneeId/page/perPage. +# @name leadSearch +POST {{baseUrl}}/query +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +Content-Type: application/json + +{ "action": "crm.lead.search", "variables": { "status": "new", "page": 1, "perPage": 50 } } + +@leadId = {{leadSearch.response.body.$.items[0].id}} + +### 3. Lead get (full detail incl. phones/emails/attachments + resolved names) +POST {{baseUrl}}/query +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +Content-Type: application/json + +{ "action": "crm.lead.get", "variables": { "id": "{{leadId}}" } } + +# ────────────────────── LEAD VERIFICATION: READS (POST /query) ────────────────── + +### 4. Verification stats (the clickable stat tiles) +POST {{baseUrl}}/query +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +Content-Type: application/json + +{ "action": "crm.leadVerification.stats", "variables": {} } + +### 5. Verification search (RUN THIS — provides @verificationId) +# @name vSearch +POST {{baseUrl}}/query +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +Content-Type: application/json + +{ "action": "crm.leadVerification.search", "variables": { "page": 1, "perPage": 50 } } + +@verificationId = {{vSearch.response.body.$.items[0].id}} + +### 6. Verification get (detail + activity timeline) +POST {{baseUrl}}/query +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +Content-Type: application/json + +{ "action": "crm.leadVerification.get", "variables": { "id": "{{verificationId}}" } } + +### 7. Verification activity list (also embedded in .get) +POST {{baseUrl}}/query +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +Content-Type: application/json + +{ "action": "crm.leadVerification.activity.list", "variables": { "id": "{{verificationId}}" } } + +# ─────────────── VERIFICATION: WRITES (POST /command + unique key) ─────────────── +# Commands require a UNIQUE X-Idempotency-Key each time (reuse ⇒ 409). +# Records "arrive" via intake (§12.11) — start the working flow at #10. + +### 10. Intake — seed a verification record (RUN — provides @seedVerificationId) +# @name intake +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lv-intake-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.intake", "variables": { + "name": "Kevin Hartley", + "phone": "(972) 413-8902", + "email": "kevin.hartley@example.com", + "address": "2814 Ravenswood Dr, Plano, TX 75023", + "source": "Door Knock" +} } + +@seedVerificationId = {{intake.response.body.$.id}} + +### 11. Assign (Change Assignee) — pending → assigned +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lv-assign-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.assign", "variables": { "id": "{{seedVerificationId}}", "assigneeId": "{{principal}}" } } + +### 12. Reassign → In Progress +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lv-reassign-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.reassign", "variables": { "id": "{{seedVerificationId}}", "assigneeId": "{{principal}}" } } + +### 13. Update status + sub-status (generic; cannot set 'verified' here — use #15) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lv-updatestatus-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.updateStatus", "variables": { "id": "{{seedVerificationId}}", "status": "in_progress", "subStatus": "Reviewing Insurance" } } + +### 14. Add a verification note (appends + timeline entry) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lv-note-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.note.add", "variables": { "id": "{{seedVerificationId}}", "text": "Ownership confirmed via county records." } } + +### 15. Verify — PROMOTES to a new Lead (status = new). Returns { verification, lead }. +# @name verify +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lv-verify-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.verify", "variables": { "id": "{{seedVerificationId}}", "notes": "Verified — insurance + damage confirmed." } } + +@promotedLeadId = {{verify.response.body.$.lead.id}} + +### 16. Get the promoted Lead (created by #15) +POST {{baseUrl}}/query +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +Content-Type: application/json + +{ "action": "crm.lead.get", "variables": { "id": "{{promotedLeadId}}" } } + +### 17. Move to Pending (send an assigned/in_progress record back). Seed a second record first if needed. +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lv-topending-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.moveToPending", "variables": { "id": "{{verificationId}}" } } + +### 18. Mark Unverified (any non-terminal record) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lv-unverified-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.markUnverified", "variables": { "id": "{{verificationId}}", "reason": "Could not confirm ownership." } } + +# ─────────────────────── LEADS: WRITES (POST /command + unique key) ────────────── + +### 20. Create Lead — Full Form payload (RUN — provides @createdLeadId) +# @name createLead +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lead-create-001 +Content-Type: application/json + +{ "action": "crm.lead.create", "variables": { + "firstName": "John", + "lastName": "Martinez", + "phones": [ + { "number": "(469) 500-1000", "type": "Mobile", "primary": true }, + { "number": "(469) 500-2000", "type": "Home" } + ], + "emails": [ { "address": "john.martinez@example.com", "primary": true } ], + "property": { "address": "4821 Spring Creek Pkwy", "city": "Plano", "state": "TX", "zip": "75023", "type": "Single Family" }, + "job": { "source": "Door Knock", "canvasserId": "{{principal}}", "leadType": "Insurance", "workType": "Roof Replacement", "tradeType": "Roofing", "urgency": "High", "notes": "Significant granule loss on the south slope." }, + "insurance": { "company": "State Farm", "claimStatus": "Filed", "claimNumber": "CLM-2026-1000", "policyNumber": "POL-080000", "adjusterName": "Marcus Powell", "adjusterPhone": "(972) 700-3000" }, + "assignment": { "assigneeId": "{{principal}}", "priority": "High", "followUp": "2026-06-04" } +} } + +@createdLeadId = {{createLead.response.body.$.id}} + +### 21. Update Lead (partial patch; priority title-case is normalized to lowercase) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lead-update-001 +Content-Type: application/json + +{ "action": "crm.lead.update", "variables": { "id": "{{createdLeadId}}", "patch": { "priority": "Medium", "jobNotes": "Adjuster meeting scheduled." } } } + +### 22. Update Status (audited → lead_status_history) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lead-status-001 +Content-Type: application/json + +{ "action": "crm.lead.updateStatus", "variables": { "id": "{{createdLeadId}}", "status": "contacted", "note": "Spoke with homeowner." } } + +### 23. Assign a rep (or null for Unassigned) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lead-assign-001 +Content-Type: application/json + +{ "action": "crm.lead.assign", "variables": { "id": "{{createdLeadId}}", "assigneeId": "{{principal}}" } } + +### 24. Attachment add (site photo) — contentRef comes from crm.media.presignUpload (§11.5) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lead-attach-add-001 +Content-Type: application/json + +{ "action": "crm.lead.attachment.add", "variables": { "id": "{{createdLeadId}}", "attachment": { "contentRef": "obj/site-photo-1.jpg", "mimeType": "image/jpeg", "sizeBytes": 1048576, "filename": "roof-south.jpg" } } } + +### 25. Attachment remove (attachmentId from the lead's attachments[] in #3/#16) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: lead-attach-remove-001 +Content-Type: application/json + +{ "action": "crm.lead.attachment.remove", "variables": { "id": "{{createdLeadId}}", "attachmentId": "REPLACE_WITH_ATTACHMENT_ID" } } + +# ─────────────────────────── NEGATIVE / GUARD CHECKS ──────────────────────────── + +### Verify an already-verified record (expect 409 already_verified) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: guard-alreadyverified-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.verify", "variables": { "id": "{{seedVerificationId}}" } } + +### Create with no name (expect 422 name_required) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: guard-noname-001 +Content-Type: application/json + +{ "action": "crm.lead.create", "variables": { "firstName": "", "lastName": " " } } + +### Create with a bad email (expect 400 validation) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: guard-bademail-001 +Content-Type: application/json + +{ "action": "crm.lead.create", "variables": { "firstName": "Jane", "emails": [ { "address": "not-an-email" } ] } } + +### Invalid state transition — verified is terminal via generic updateStatus (expect 409 / 422) +POST {{baseUrl}}/command +Authorization: Bearer dev +X-App-ID: crm-web +X-Tenant-ID: {{tenant}} +X-Principal-ID: {{principal}} +X-Idempotency-Key: guard-transition-001 +Content-Type: application/json + +{ "action": "crm.leadVerification.updateStatus", "variables": { "id": "{{seedVerificationId}}", "status": "pending" } } diff --git a/leads.postman_collection.json b/leads.postman_collection.json new file mode 100644 index 0000000..65685c5 --- /dev/null +++ b/leads.postman_collection.json @@ -0,0 +1,554 @@ +{ + "info": { + "name": "be-crm — Leads & Lead Verification (data door)", + "description": "All Leads (crm.lead.*) and Lead Verification (crm.leadVerification.*) data-door actions — for local testing against be-crm on :4010 (trust-headers mode).\n\nHOW TO USE:\n1. Import this file into Postman.\n2. Run 'Verification writes / 10. intake' FIRST — its test script captures the new record id into {{seedVerificationId}}, which the assign/verify chain references. The cleanest end-to-end path is: 10 intake → 11 assign → 12 reassign → 15 verify (promotes to a Lead, captured into {{promotedLeadId}}).\n3. The read folders ('Leads reads' / 'Verification reads') capture {{leadId}} / {{verificationId}} from search results when data exists.\n\nVERIFICATION STATE MACHINE (enforced server-side, §3.1): a record flows pending → assigned → in_progress, and ends at ONE terminal outcome — either verified (via 15. verify, which promotes a Lead) OR unverified (via 17. markUnverified). 'verified' is TERMINAL: markUnverified / moveToPending / updateStatus against a verified record return 409 invalid_state_transition. So run ONE terminal action per record. To exercise markUnverified or moveToPending, run 10. intake to seed a FRESH pending record (repoints {{verificationId}}) and act on it BEFORE verifying — e.g. intake → assign → moveToPending, or intake → markUnverified. Valid: markUnverified from pending/assigned/in_progress; moveToPending from assigned/in_progress.\n\nNOTE: everything is POST except GET /health. Reads hit /query, writes hit /command (action+data in the JSON body). Commands auto-generate a fresh X-Idempotency-Key ({{$guid}}). In dev, the identity headers stand in for what the Shell BFF injects in real environments — never ship them from the frontend. Both modules are gated by the leads.manage permission.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:4010" }, + { "key": "tenant", "value": "tnt_demo" }, + { "key": "principal", "value": "prn_owner" }, + { "key": "leadId", "value": "" }, + { "key": "verificationId", "value": "" }, + { "key": "seedVerificationId", "value": "" }, + { "key": "promotedLeadId", "value": "" }, + { "key": "createdLeadId", "value": "" }, + { "key": "attachmentId", "value": "" } + ], + "item": [ + { + "name": "0. Health (the only GET)", + "request": { + "method": "GET", + "header": [], + "url": { "raw": "{{baseUrl}}/health", "host": ["{{baseUrl}}"], "path": ["health"] }, + "description": "Liveness + the full list of registered actions (crm.lead.* + crm.leadVerification.*). Works in a browser." + } + }, + { + "name": "Leads reads (POST /query)", + "item": [ + { + "name": "1. lead.stats (header stat strip)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.stats\",\n \"variables\": {}\n}" }, + "url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] }, + "description": "Returns { total, byStatus: { new, contacted, appointed, closed } }." + } + }, + { + "name": "2. lead.search (captures leadId)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const j = pm.response.json();", + "if (j.items && j.items[0]) pm.collectionVariables.set('leadId', j.items[0].id);", + "pm.test('has paging meta', () => pm.expect(j.meta).to.have.property('total'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.search\",\n \"variables\": { \"status\": \"new\", \"page\": 1, \"perPage\": 50 }\n}" }, + "url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] }, + "description": "Board list + search + status/priority/source/assigneeId filters. Returns { items: LeadCardDTO[], meta }." + } + }, + { + "name": "3. lead.get (full detail)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const j = pm.response.json();", + "if (j.attachments && j.attachments[0]) pm.collectionVariables.set('attachmentId', j.attachments[0].id);" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.get\",\n \"variables\": { \"id\": \"{{leadId}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] }, + "description": "Full record incl. phones/emails/attachments + resolved assignee/canvasser/creator names + storm/property/job/insurance groupings." + } + } + ] + }, + { + "name": "Verification reads (POST /query)", + "item": [ + { + "name": "4. leadVerification.stats (stat tiles)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.stats\",\n \"variables\": {}\n}" }, + "url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] }, + "description": "Returns { verified, in_progress, assigned, pending, unverified }." + } + }, + { + "name": "5. leadVerification.search (captures verificationId)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const j = pm.response.json();", + "if (j.items && j.items[0]) pm.collectionVariables.set('verificationId', j.items[0].id);", + "pm.test('has paging meta', () => pm.expect(j.meta).to.have.property('total'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.search\",\n \"variables\": { \"page\": 1, \"perPage\": 50 }\n}" }, + "url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] }, + "description": "Queue table + search + status/source/assignee filters. Returns { items: VerificationRowDTO[], meta }." + } + }, + { + "name": "6. leadVerification.get (detail + timeline)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.get\",\n \"variables\": { \"id\": \"{{verificationId}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] } + } + }, + { + "name": "7. leadVerification.activity.list", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.activity.list\",\n \"variables\": { \"id\": \"{{verificationId}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] } + } + } + ] + }, + { + "name": "Verification writes (POST /command)", + "item": [ + { + "name": "10. leadVerification.intake (RUN FIRST — captures seedVerificationId)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const j = pm.response.json();", + "if (j && j.id) { pm.collectionVariables.set('seedVerificationId', j.id); pm.collectionVariables.set('verificationId', j.id); }", + "pm.test('starts pending', () => pm.expect(j.status).to.eql('pending'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.intake\",\n \"variables\": {\n \"name\": \"Kevin Hartley\",\n \"phone\": \"(972) 413-8902\",\n \"email\": \"kevin.hartley@example.com\",\n \"address\": \"2814 Ravenswood Dr, Plano, TX 75023\",\n \"source\": \"Door Knock\"\n }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Records 'arrive' via intake (§12.11) — the ingestion path outside the two UI screens. Seeds the queue and captures {{seedVerificationId}} for the working flow below." + } + }, + { + "name": "11. leadVerification.assign (pending → assigned)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.assign\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"assigneeId\": \"{{principal}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Change Assignee. A pending record moves to 'assigned'; otherwise only the assignee changes." + } + }, + { + "name": "12. leadVerification.reassign (→ In Progress)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.reassign\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"assigneeId\": \"{{principal}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] } + } + }, + { + "name": "13. leadVerification.updateStatus (generic; not 'verified')", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.updateStatus\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"status\": \"in_progress\", \"subStatus\": \"Reviewing Insurance\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Generic status + sub-status set. Setting 'verified' here → 422 (use 15. verify, which promotes a Lead). Disallowed transitions → 409." + } + }, + { + "name": "14. leadVerification.note.add", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.note.add\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"text\": \"Ownership confirmed via county records.\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] } + } + }, + { + "name": "15. leadVerification.verify (PROMOTES to Lead — captures promotedLeadId)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const j = pm.response.json();", + "if (j.lead && j.lead.id) pm.collectionVariables.set('promotedLeadId', j.lead.id);", + "pm.test('verification is verified', () => pm.expect(j.verification.status).to.eql('verified'));", + "pm.test('promoted lead is new', () => pm.expect(j.lead.status).to.eql('new'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.verify\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"notes\": \"Verified — insurance + damage confirmed.\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "The critical cross-module command: flips the record to verified and creates a new Lead (status=new) in one transaction, linking both records. Returns { verification, lead }. A second call → 409 already_verified." + } + }, + { + "name": "16. leadVerification.moveToPending", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.moveToPending\",\n \"variables\": { \"id\": \"{{verificationId}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Send an assigned/in_progress record back to pending (keeps the assignee). Point at a non-verified {{verificationId}}." + } + }, + { + "name": "17. leadVerification.markUnverified", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.markUnverified\",\n \"variables\": { \"id\": \"{{verificationId}}\", \"reason\": \"Could not confirm ownership.\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Terminal outcome. Allowed from pending/assigned/in_progress; a verified record → 409. Point {{verificationId}} at a NON-verified record (run 10. intake to seed a fresh pending one)." + } + } + ] + }, + { + "name": "Leads writes (POST /command)", + "item": [ + { + "name": "20. lead.create (Full Form — captures createdLeadId)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const j = pm.response.json();", + "if (j && j.id) { pm.collectionVariables.set('createdLeadId', j.id); pm.collectionVariables.set('leadId', j.id); }", + "pm.test('priority normalized to lowercase', () => pm.expect(j.priority).to.eql('high'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.create\",\n \"variables\": {\n \"firstName\": \"John\",\n \"lastName\": \"Martinez\",\n \"phones\": [\n { \"number\": \"(469) 500-1000\", \"type\": \"Mobile\", \"primary\": true },\n { \"number\": \"(469) 500-2000\", \"type\": \"Home\" }\n ],\n \"emails\": [ { \"address\": \"john.martinez@example.com\", \"primary\": true } ],\n \"property\": { \"address\": \"4821 Spring Creek Pkwy\", \"city\": \"Plano\", \"state\": \"TX\", \"zip\": \"75023\", \"type\": \"Single Family\" },\n \"job\": { \"source\": \"Door Knock\", \"canvasserId\": \"{{principal}}\", \"leadType\": \"Insurance\", \"workType\": \"Roof Replacement\", \"tradeType\": \"Roofing\", \"urgency\": \"High\", \"notes\": \"Significant granule loss on the south slope.\" },\n \"insurance\": { \"company\": \"State Farm\", \"claimStatus\": \"Filed\", \"claimNumber\": \"CLM-2026-1000\", \"policyNumber\": \"POL-080000\", \"adjusterName\": \"Marcus Powell\", \"adjusterPhone\": \"(972) 700-3000\" },\n \"assignment\": { \"assigneeId\": \"{{principal}}\", \"priority\": \"High\", \"followUp\": \"2026-06-04\" }\n }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Quick + Full form intake. Only a non-empty name (firstName OR lastName) is required (else 422 name_required). Title-case priority is normalized to lowercase; the first phone is marked primary when none is flagged. DUPLICATE GUARD: if an active (non-closed) lead in the tenant has BOTH the same normalized primary phone AND the same address, the create is REJECTED with 409 duplicate_lead (the response's duplicateOf lists the existing lead) and no lead is inserted." + } + }, + { + "name": "21. lead.update (partial patch)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.update\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"patch\": { \"priority\": \"Medium\", \"jobNotes\": \"Adjuster meeting scheduled.\" } }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] } + } + }, + { + "name": "22. lead.updateStatus (audited)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.updateStatus\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"status\": \"contacted\", \"note\": \"Spoke with homeowner.\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Appends a row to lead_status_history." + } + }, + { + "name": "23. lead.assign (rep; null for Unassigned)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.assign\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"assigneeId\": \"{{principal}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] } + } + }, + { + "name": "24. lead.attachment.add (site photo — captures attachmentId)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const j = pm.response.json();", + "if (j.attachments && j.attachments.length) pm.collectionVariables.set('attachmentId', j.attachments[j.attachments.length - 1].id);" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.attachment.add\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"attachment\": { \"contentRef\": \"obj/site-photo-1.jpg\", \"mimeType\": \"image/jpeg\", \"sizeBytes\": 1048576, \"filename\": \"roof-south.jpg\" } }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "contentRef is the object key returned by crm.media.presignUpload after the browser PUTs the bytes (§11.5). Files over 25 MB → 413 file_too_large." + } + }, + { + "name": "25. lead.attachment.remove", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.attachment.remove\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"attachmentId\": \"{{attachmentId}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Run 24 first so {{attachmentId}} is captured." + } + } + ] + }, + { + "name": "Negative / guard checks", + "item": [ + { + "name": "Verify an already-verified record (expect 409 already_verified)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.verify\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "Run 15. verify first — a second verify on the same record is rejected (no duplicate Lead)." + } + }, + { + "name": "Create with no name (expect 422 name_required)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.create\",\n \"variables\": { \"firstName\": \"\", \"lastName\": \" \" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] } + } + }, + { + "name": "Create with a bad email (expect 400 validation)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.create\",\n \"variables\": { \"firstName\": \"Jane\", \"emails\": [ { \"address\": \"not-an-email\" } ] }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] } + } + }, + { + "name": "Verify via generic updateStatus (expect 422 — use verify)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer dev" }, + { "key": "X-App-ID", "value": "crm-web" }, + { "key": "X-Tenant-ID", "value": "{{tenant}}" }, + { "key": "X-Principal-ID", "value": "{{principal}}" }, + { "key": "X-Idempotency-Key", "value": "{{$guid}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.updateStatus\",\n \"variables\": { \"id\": \"{{verificationId}}\", \"status\": \"verified\" }\n}" }, + "url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }, + "description": "The generic status setter refuses 'verified' — verification must go through crm.leadVerification.verify (which promotes a Lead)." + } + } + ] + } + ] +} diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index 434eef4..33aa206 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -1278,8 +1278,23 @@ .dash-root .nl-photos-s { font-size: 11px; } .dash-root .nl-photo-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } .dash-root .nl-photo-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 99px; background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } + .dash-root .nl-photo-chip-x { display: inline-flex; align-items: center; justify-content: center; margin-left: 2px; padding: 0; border: none; background: none; color: inherit; opacity: 0.65; cursor: pointer; } + .dash-root .nl-photo-chip-x:hover { opacity: 1; } .dash-root .ds-select.is-placeholder { color: var(--faint, var(--muted)); } + /* Detail popup — property photo gallery */ + .dash-root .ld-photo-block { margin-bottom: 14px; } + .dash-root .ld-photo-block .ld-section-head { margin-bottom: 8px; } + .dash-root .ld-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: 8px; } + .dash-root .ld-photo { position: relative; display: block; aspect-ratio: 1; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-2); background: var(--panel-2); cursor: pointer; } + .dash-root .ld-photo img { width: 100%; height: 100%; object-fit: cover; display: block; } + .dash-root .ld-photo-ph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); } + /* Edit mode — photo tile with a remove badge */ + .dash-root .nl-edit-photos { margin-bottom: 8px; } + .dash-root .ld-photo-edit { position: relative; } + .dash-root .ld-photo-x { position: absolute; top: 4px; right: 4px; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border: none; border-radius: 99px; background: rgba(0,0,0,0.6); color: #fff; cursor: pointer; } + .dash-root .ld-photo-x:hover { background: var(--red, #e5484d); } + /* Canvasser search (Door Knock source) */ .dash-root .nl-canvasser { position: relative; } .dash-root .nl-canvasser-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30; max-height: 220px; overflow-y: auto; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel); box-shadow: 0 12px 32px rgba(0,0,0,0.28); padding: 5px; } @@ -1358,6 +1373,21 @@ .lv-menu-item svg { color: var(--muted, #8c8c8c); flex: 0 0 auto; } .lv-menu-item:hover svg { color: var(--orange, #fda913); } + /* ---- change-assignee popup ---- */ + .dash-root .lv-assign { display: flex; flex-direction: column; gap: 12px; } + .dash-root .lv-assign-search { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border-radius: 11px; border: 1px solid var(--border); background: var(--panel-2); } + .dash-root .lv-assign-search svg { color: var(--muted); flex: 0 0 auto; } + .dash-root .lv-assign-search input { flex: 1; min-width: 0; border: 0; background: none; color: var(--text); font-family: inherit; font-size: 13px; outline: none; } + .dash-root .lv-assign-x { display: inline-flex; border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0; } + .dash-root .lv-assign-x:hover { color: var(--text); } + .dash-root .lv-assign-list { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow-y: auto; margin: -4px; padding: 4px; } + .dash-root .lv-assign-opt { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 10px; background: none; color: var(--text-2); font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; text-align: left; } + .dash-root .lv-assign-opt:hover { background: var(--panel-3, #1b1b22); color: var(--text); } + .dash-root .lv-assign-opt.current { background: color-mix(in srgb, var(--orange) 12%, transparent); } + .dash-root .lv-assign-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dash-root .lv-assign-opt.current > svg { color: var(--orange); flex: 0 0 auto; } + .dash-root .lv-assign-empty { padding: 20px 10px; text-align: center; color: var(--muted); font-size: 12.5px; } + /* ---- verification detail popup ---- */ .dash-root .lv-detail { display: flex; flex-direction: column; gap: 16px; } .dash-root .lv-d-identity { display: flex; align-items: center; gap: 14px; } diff --git a/src/components/dashboard/leads-data.ts b/src/components/dashboard/leads-data.ts index a4a5a82..92e1ed0 100644 --- a/src/components/dashboard/leads-data.ts +++ b/src/components/dashboard/leads-data.ts @@ -12,9 +12,16 @@ export type LeadPriority = "high" | "medium" | "low"; export type Phone = { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean }; export type Email = { address: string; type?: string; primary?: boolean }; +/** A site photo (or other file) stored via the media presign flow (§11.5). + * `contentRef` is the IIOS object key — render it through a signed download URL. */ +export type Attachment = { id?: string; contentRef: string; mimeType: string; sizeBytes: number; filename: string }; export type Lead = { - id: string; // SAL-001 + id: string; // SAL-001 (display code) + refId?: string; // opaque server id (live mode); commands target this — falls back to `id` + /** Set once the lead has been pushed to the verification queue — blocks a duplicate send. + * Populated from the backend (e.g. LeadDTO.verificationId) when available. */ + verificationId?: string; initials: string; name: string; gradient: string; @@ -31,6 +38,9 @@ export type Lead = { phones: Phone[]; emails: Email[]; + // site photos (uploaded via the media presign flow; shown in the detail popup) + attachments?: Attachment[]; + // property property: { address: string; city: string; state: string; zip: string; type: string }; diff --git a/src/components/dashboard/leads.tsx b/src/components/dashboard/leads.tsx index c55fd82..f23c9bb 100644 --- a/src/components/dashboard/leads.tsx +++ b/src/components/dashboard/leads.tsx @@ -11,13 +11,15 @@ // Data comes from leads-data.ts (client-side mock). // ============================================================ -import { useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui"; import { - LEADS, TOTAL_LEADS, STATUS_META, PRIORITY_META, - REPS, LEAD_SOURCES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES, - type Lead, type LeadStatus, + STATUS_META, PRIORITY_META, + LEAD_SOURCES, LEAD_TYPES, PROPERTY_TYPES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES, + type Lead, type LeadStatus, type Rep, type Attachment, } from "./leads-data"; +import { useLeadsData, type CreateLeadInput, type LeadsData } from "@/lib/leads-api"; +import { MAX_ATTACHMENT_BYTES, isImage, type UploadedAttachment } from "@/lib/media-api"; const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [ { value: "all", label: "All" }, @@ -28,44 +30,50 @@ const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [ ]; export function Leads() { - const toast = useToast(); + const data = useLeadsData(); + const { leads, total, byStatus, loading, error } = data; const [query, setQuery] = useState(""); const [filter, setFilter] = useState<"all" | LeadStatus>("all"); const [selected, setSelected] = useState(null); const [newOpen, setNewOpen] = useState(false); - - const countByStatus = useMemo(() => { - const m: Record = {}; - for (const l of LEADS) m[l.status] = (m[l.status] ?? 0) + 1; - return m; - }, []); + // Leads already pushed to the verification queue this session — blocks a second send + // (interim guard until the backend exposes a persistent verification status; see notes). + const [sentIds, setSentIds] = useState>(() => new Set()); + const idOf = (l: Lead) => l.refId ?? l.id; const filtered = useMemo(() => { const q = query.trim().toLowerCase(); - return LEADS.filter((l) => { + return leads.filter((l) => { const matchStatus = filter === "all" || l.status === filter; const hay = `${l.name} ${l.property.address} ${l.property.city} ${l.job.source} ${l.job.canvasser}`.toLowerCase(); return matchStatus && (!q || hay.includes(q)); }); - }, [query, filter]); + }, [leads, query, filter]); + + // Open a card immediately with its list-level data, then hydrate the full detail (live mode + // list rows carry card fields only). Ignore hydration errors — the card data still renders. + const openLead = (l: Lead) => { + setSelected(l); + data.getLead(l).then((full) => setSelected((cur) => (cur && cur.id === l.id ? full : cur))).catch(() => {}); + }; return (
setNewOpen(true)}>New Lead} /> {/* ---- stat strip ---------------------------------------- */}
- - - - - + + + + +
{/* ---- toolbar ------------------------------------------- */} @@ -96,7 +104,19 @@ export function Leads() {
{/* ---- board --------------------------------------------- */} - {filtered.length === 0 ? ( + {error ? ( +
+ +

Couldn’t load leads

+

{error}

+
+ ) : loading && filtered.length === 0 ? ( +
+ +

Loading leads…

+

Fetching the Plano pipeline.

+
+ ) : filtered.length === 0 ? (

No leads match

@@ -105,13 +125,21 @@ export function Leads() { ) : (
{filtered.map((l) => ( - setSelected(l)} /> + openLead(l)} /> ))}
)} - setSelected(null)} /> - setNewOpen(false)} /> + setSelected(null)} + data={data} + reps={data.reps} + onHydrate={setSelected} + sent={!!selected && sentIds.has(idOf(selected))} + onSent={() => selected && setSentIds((s) => new Set(s).add(idOf(selected)))} + /> + setNewOpen(false)} createLead={data.createLead} reps={data.reps} uploadPhoto={data.uploadPhoto} />
); } @@ -149,7 +177,7 @@ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
{lead.name}
-
{lead.id} · {lead.tag}
+
{lead.id}
{priority.label} @@ -172,116 +200,365 @@ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) { /* Lead detail popup */ /* ---------------------------------------------------------- */ -function LeadDetail({ lead, onClose }: { lead: Lead | null; onClose: () => void }) { +type LeadEdit = { + firstName: string; lastName: string; + phones: PhoneRow[]; emails: EmailRow[]; + address: string; city: string; state: string; zip: string; propertyType: string; + source: string; leadType: string; workType: string; tradeType: string; urgency: string; notes: string; + insCompany: string; claimStatus: string; claimNumber: string; policyNumber: string; adjusterName: string; adjusterPhone: string; + assignRep: string; priority: string; followUp: string; + photos: Attachment[]; +}; + +// Convert a pretty date ("Jun 4, 2026") — or "—"/"" — to a yyyy-mm-dd value for . +function toDateInput(s: string): string { + if (!s || s === "—") return ""; + const t = Date.parse(s); + if (Number.isNaN(t)) return ""; + const d = new Date(t); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function initEdit(lead: Lead, reps: Rep[]): LeadEdit { + const [firstName, ...rest] = lead.name.split(/\s+/); + const rep = reps.find((r) => r.name === lead.assignment.assignedTo); + return { + firstName: firstName ?? "", lastName: rest.join(" "), + phones: lead.phones.length ? lead.phones.map((p) => ({ number: p.number, type: p.type })) : [{ number: "", type: "Mobile" }], + emails: lead.emails.map((e) => ({ address: e.address })), + address: lead.property.address, city: lead.property.city, state: lead.property.state, zip: lead.property.zip, propertyType: lead.property.type, + source: lead.job.source, leadType: lead.job.leadType, workType: lead.job.workType, tradeType: lead.job.tradeType, urgency: lead.job.urgency || "Standard", notes: lead.job.notes, + insCompany: lead.insurance.company, claimStatus: lead.insurance.claimStatus, claimNumber: lead.insurance.claimNumber, + policyNumber: lead.insurance.policyNumber, adjusterName: lead.insurance.adjusterName, adjusterPhone: lead.insurance.adjusterPhone, + assignRep: rep?.id ?? "", priority: lead.assignment.priority || "Medium", followUp: toDateInput(lead.assignment.followUp), + photos: lead.attachments ?? [], + }; +} + +function buildPatch(f: LeadEdit): import("@/lib/leads-api").LeadPatch { + return { + firstName: f.firstName.trim(), lastName: f.lastName.trim(), + phones: f.phones.filter((p) => p.number.trim()).map((p, i) => ({ number: p.number.trim(), type: p.type, primary: i === 0 })), + emails: f.emails.filter((e) => e.address.trim()).map((e, i) => ({ address: e.address.trim(), primary: i === 0 })), + propertyAddress: f.address, propertyCity: f.city, propertyState: f.state, propertyZip: f.zip, propertyType: f.propertyType || undefined, + source: f.source || undefined, leadType: f.leadType || undefined, workType: f.workType || undefined, + tradeType: f.tradeType || undefined, urgency: f.urgency || undefined, jobNotes: f.notes, + insuranceCompany: f.insCompany, insuranceClaimStatus: f.claimStatus || undefined, insuranceClaimNumber: f.claimNumber, + insurancePolicyNumber: f.policyNumber, insuranceAdjusterName: f.adjusterName, insuranceAdjusterPhone: f.adjusterPhone, + assignedToId: f.assignRep || null, followUpDate: f.followUp || undefined, priority: f.priority, + }; +} + +function LeadDetail({ lead, onClose, data, reps, onHydrate, sent, onSent }: { lead: Lead | null; onClose: () => void; data: LeadsData; reps: Rep[]; onHydrate: (l: Lead) => void; sent: boolean; onSent: () => void }) { if (!lead) return null; + // Keyed by id so edit state resets when a different lead opens. + return ; +} + +function LeadDetailBody({ lead, onClose, data, reps, onHydrate, sent, onSent }: { lead: Lead; onClose: () => void; data: LeadsData; reps: Rep[]; onHydrate: (l: Lead) => void; sent: boolean; onSent: () => void }) { + const toast = useToast(); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [sending, setSending] = useState(false); + const [f, setF] = useState(null); + const [uploading, setUploading] = useState(0); + const editFileRef = useRef(null); + const status = STATUS_META[lead.status]; const priority = PRIORITY_META[lead.priority]; + const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0]; + const primaryEmail = lead.emails.find((e) => e.primary) ?? lead.emails[0]; + const repOptions = [{ id: "", initials: "—", name: "Unassigned", email: "" }, ...reps]; + + const call = () => { + if (!primaryPhone?.number) { toast.push({ tone: "error", title: "No phone", desc: "This lead has no phone number." }); return; } + window.location.href = `tel:${primaryPhone.number.replace(/[^\d+]/g, "")}`; + }; + const email = () => { + if (!primaryEmail?.address) { toast.push({ tone: "error", title: "No email", desc: "This lead has no email address." }); return; } + window.location.href = `mailto:${primaryEmail.address}`; + }; + + const startEdit = () => { setF(initEdit(lead, reps)); setEditing(true); }; + const cancelEdit = () => { setEditing(false); setF(null); }; + const setField = (k: keyof LeadEdit) => (e: { target: { value: string } }) => setF((s) => (s ? { ...s, [k]: e.target.value } : s)); + const addPhone = () => setF((s) => (s ? { ...s, phones: [...s.phones, { number: "", type: "Mobile" }] } : s)); + const setPhone = (i: number, key: "number" | "type", v: string) => setF((s) => (s ? { ...s, phones: s.phones.map((p, j) => (j === i ? { ...p, [key]: v } : p)) } : s)); + const removePhone = (i: number) => setF((s) => (s ? { ...s, phones: s.phones.filter((_, j) => j !== i) } : s)); + const addEmail = () => setF((s) => (s ? { ...s, emails: [...s.emails, { address: "" }] } : s)); + const setEmail = (i: number, v: string) => setF((s) => (s ? { ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) } : s)); + const removeEmail = (i: number) => setF((s) => (s ? { ...s, emails: s.emails.filter((_, j) => j !== i) } : s)); + const removeEditPhoto = (contentRef: string) => setF((s) => (s ? { ...s, photos: s.photos.filter((p) => p.contentRef !== contentRef) } : s)); + + // Upload picked files to storage (presign → PUT), then stage them on the edit form. They're + // attached to the lead on Save via crm.lead.attachment.add (see the reconcile in save()). + async function onPickEditPhotos(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + e.target.value = ""; + for (const file of files) { + if (!isImage(file.type)) { toast.push({ tone: "error", title: "Not an image", desc: `${file.name} isn't an image file.` }); continue; } + if (file.size > MAX_ATTACHMENT_BYTES) { toast.push({ tone: "error", title: "Too large", desc: `${file.name} exceeds the 25 MB limit.` }); continue; } + setUploading((n) => n + 1); + try { + const up = await data.uploadPhoto(file); + setF((s) => (s ? { ...s, photos: [...s.photos, up] } : s)); + } catch (err) { + toast.push({ tone: "error", title: "Upload failed", desc: err instanceof Error ? err.message : `Couldn't upload ${file.name}.` }); + } finally { + setUploading((n) => n - 1); + } + } + } + + async function save() { + if (!f) return; + if (!`${f.firstName} ${f.lastName}`.trim()) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; } + setSaving(true); + try { + await data.updateLead(lead, buildPatch(f)); + // Reconcile photos against what the lead had: attach the newly-added, detach the removed. + const orig = lead.attachments ?? []; + const toAdd = f.photos.filter((p) => !orig.some((o) => o.contentRef === p.contentRef)); + const toRemove = orig.filter((o) => !f.photos.some((p) => p.contentRef === o.contentRef)); + for (const a of toAdd) await data.addPhoto(lead, a); + for (const r of toRemove) await data.removePhoto(lead, r); + try { onHydrate(await data.getLead(lead)); } catch { /* keep current view if re-fetch fails */ } + toast.push({ tone: "success", title: "Lead updated", desc: `${`${f.firstName} ${f.lastName}`.trim()} saved.` }); + setEditing(false); setF(null); + } catch (e) { + toast.push({ tone: "error", title: "Update failed", desc: e instanceof Error ? e.message : "Please try again." }); + } finally { setSaving(false); } + } + + // Already in the verification queue if the backend says so (verificationId) or we sent it this session. + const alreadySent = sent || !!lead.verificationId; + + async function sendForVerification() { + if (alreadySent) return; + setSending(true); + try { + await data.sendForVerification(lead); + onSent(); + toast.push({ tone: "success", title: "Sent for verification", desc: `${lead.name} added to the verification queue.` }); + } catch (e) { + toast.push({ tone: "error", title: "Couldn’t send", desc: e instanceof Error ? e.message : "Please try again." }); + } finally { setSending(false); } + } return ( - Call - Email - Update Status + Cancel + {saving ? "Saving…" : "Save Changes"} - } + ) : ( + <> + Edit + Call + Email + + {alreadySent ? "Sent for Verification" : sending ? "Sending…" : "Send for Verification"} + + + )} > -
- {/* identity strip */} -
- -
-
{lead.name}
-
- {status.label} - {priority.label} + {editing && f ? ( +
+
Contact
+
+ + +
+
Phone Numbers
+ {f.phones.map((p, i) => ( +
+ setPhone(i, "number", e.target.value)} placeholder="(555) 000-0000" /> + + {f.phones.length > 1 && } +
+ ))} + +
+
+
Email Addresses
+ {f.emails.length === 0 &&
No emails added yet.
} + {f.emails.map((em, i) => ( +
+ setEmail(i, e.target.value)} placeholder="name@email.com" /> + +
+ ))} +
-
- {/* storm banner */} -
- -
-
{lead.storm.zone}
-
{lead.storm.date} · {lead.storm.detail}
+
Property
+
+
+ + + + + {f.photos.length > 0 && ( +
+ {f.photos.map((p) => ( +
+ + +
+ ))} +
+ )} + +
+
+ +
Job Details
+
+ + +