Leads & CRM — Code Map
API
Module — leads (public lead intake)
| File | Purpose |
|---|---|
apps/api/src/leads/leads.module.ts | Wires LeadsController + LeadsService + cross-import to OrganizationLeadsService. |
apps/api/src/leads/leads.controller.ts | Two public endpoints: platform waitlist + minisite contact form. |
apps/api/src/leads/leads.service.ts | Tiny — only handles platform leads (inserts leads + platform_leads). |
Module — organization-leads (staff CRM)
| File | Purpose |
|---|---|
apps/api/src/organization-leads/organization-leads.module.ts | Wires the controllers + services below (incl. the lead-bulk-email BullMQ queue). |
apps/api/src/organization-leads/organization-leads.controller.ts | All staff CRM routes under organizations/:orgId/leads. Decorated with @RequiresFeature('lead_management'). |
apps/api/src/organization-leads/organization-leads.service.ts | List/get/create/update/convert/import/analytics + multi-pipeline membership (add/remove/move, bulk add, bulk stage move) + the unified-timeline composition (buildTimeline). Owns dedup + auto-task + status event history. |
apps/api/src/organization-leads/lead-activity.service.ts | Append/read lead_activity_events (FIT-227). Best-effort writes: history never fails the operation it records. |
apps/api/src/organization-leads/lead-purchase.service.ts | Course-purchase CRM bookkeeping: find-or-create the per-course purchase tag, attach to the buyer’s contact, append a purchase timeline event. Called by free checkout + the payment webhook’s pending→active flip. |
apps/api/src/organization-leads/lead-bulk-email.service.ts | Bulk email audience resolution (leadIds / pipeline+stageIds[] / tags / filter, minus exclude tags, minus withdrawn consent) → fans out one BullMQ job per recipient. |
apps/api/src/organization-leads/lead-bulk-email.processor.ts | Sends one recipient per job via Resend (unsubscribe footer + lead-keyed Reply-To); records an email_sent timeline row after a successful send. |
apps/api/src/organization-leads/lead-tags.service.ts | Tag CRUD, per-contact attach/detach, bulkSetForLeads (Table multi-select → Tag), and orgLeadIdsForTags for include/exclude audiences. |
Module — lead-ingestion (webhook channels)
Auto-creates leads from Meta Lead Ads + WhatsApp webhooks. Full code map + flows in lead-ingestion.md.
| File | Purpose |
|---|---|
apps/api/src/lead-ingestion/lead-ingestion.module.ts | Wires webhook + integration controllers, per-channel services, and the BullMQ queue. |
apps/api/src/lead-ingestion/lead-ingestion.processor.ts | Queue worker; dispatches by job.name to the channel processor. |
apps/api/src/lead-ingestion/meta-*.ts | Lead Ads: OAuth connect + page-picker, leadgen webhook, Graph fetch, field mapping, processor. |
apps/api/src/lead-ingestion/whatsapp-*.ts | WhatsApp: Embedded Signup connect, CTWA messages webhook, processor. |
apps/api/src/lead-ingestion/meta-signature.util.ts | Shared X-Hub-Signature-256 HMAC (both channels). |
Module — lead-pipelines (configurable funnels)
| File | Purpose |
|---|---|
apps/api/src/lead-pipelines/lead-pipelines.module.ts | Wires controller + service. |
apps/api/src/lead-pipelines/lead-pipelines.controller.ts | Pipeline + stage CRUD under organizations/:orgId/lead-pipelines. @RequiresFeature('lead_management'). |
apps/api/src/lead-pipelines/lead-pipelines.service.ts | ensureDefaultPipeline (lazy seed, DEFAULT_STAGES), pickStageForStatus, stage CRUD + coverage guards (every pipeline keeps a new, a lost, and at least one WIN stage — converted OR customer; the last win stage may still flip between the two flavors), reorder. |
Module — conversations (unified inbox)
| File | Purpose |
|---|---|
apps/api/src/conversations/conversations.module.ts | Wires controller + inbound webhook controller + service + the channel adapters/registry. |
apps/api/src/conversations/conversations.service.ts | List/get/create/send/assign/status/read + recordInbound + recordInboundForOrgLead (lazy thread for a broadcast reply). Owns threading (subject reuse, In-Reply-To), the shared findOrCreateConversation, the assignee-membership check, and the auto-markContacted after a send/reply. |
apps/api/src/conversations/conversations.controller.ts | Staff inbox routes under organizations/:orgId/conversations. |
apps/api/src/conversations/email-inbound.controller.ts | @Public POST /webhooks/email/inbound — secret-gated (fail-closed), @Throttle; recovers the thread from a 1:1 (contact+<id>) or broadcast (contact+ol-<id>) Reply-To address. |
apps/api/src/conversations/conversation-events.ts | CONVERSATION_INBOUND_RECEIVED event + payload type. |
apps/api/src/conversations/channels/channel-adapter.ts | ChannelAdapter interface + ChannelContact. |
apps/api/src/conversations/channels/channel-registry.service.ts | Resolves an adapter by channel. |
apps/api/src/conversations/channels/{manual,email}.adapter.ts | The two live adapters (manual note; email via Resend + localized fallback subject). |
apps/api/src/conversations/channels/email-mime.ts | Zero-dep MIME parse (extractEmailText, charset-aware) + stripQuotedReply. |
apps/api/src/conversations/channels/email-threading.ts | Reply-To address build + parse for both the 1:1 (buildReplyToAddress/parseConversationId) and broadcast (buildOrgLeadReplyToAddress/parseOrgLeadId) schemes. |
Routes
| Method | Path | Handler | Auth |
|---|---|---|---|
| POST | /leads | LeadsController.createPlatformLead | @Public |
| POST | /leads/organization/:orgId | LeadsController.createMinisiteLead | @Public |
| GET | /organizations/:orgId/leads/analytics | OrganizationLeadsController.getAnalytics | Bearer + staff + lead_management |
| GET | /organizations/:orgId/leads | listLeads | same |
| GET | /organizations/:orgId/leads/:leadId | getLead | same |
| POST | /organizations/:orgId/leads | createLead | same |
| POST | /organizations/:orgId/leads/import | importLeads (bulk) | Bearer + staff + lead_management |
| POST | /organizations/:orgId/leads/bulk-email | sendBulkEmail | same |
| POST | /organizations/:orgId/leads/bulk-assign-pipeline | bulkAddLeadsToPipeline | same |
| POST | /organizations/:orgId/leads/bulk-move-stage | bulkMoveLeadsToStage | same |
| POST | /organizations/:orgId/leads/bulk-tags | bulkUpdateLeadTags | same |
| GET/POST | /organizations/:orgId/leads/:leadId/pipelines | listLeadPipelines / addLeadToPipeline | same |
| PATCH/DELETE | /organizations/:orgId/leads/:leadId/pipelines/:pipelineId | moveLeadInPipeline / removeLeadFromPipeline | same |
| POST | /organizations/:orgId/leads/:leadId/pipelines/:pipelineId/set-primary | setPrimaryPipeline | same |
| PATCH | /organizations/:orgId/leads/:leadId | updateLead | same |
| POST | /organizations/:orgId/leads/:leadId/convert | convertLead | Bearer + owner/admin + lead_management |
| GET | /organizations/:orgId/leads/:leadId/link-candidates | listLinkCandidates | Bearer + owner/admin + lead_management |
| POST | /organizations/:orgId/leads/:leadId/link-member | linkLeadToMember | Bearer + owner/admin + lead_management |
| GET/POST/PATCH | /organizations/:orgId/lead-pipelines* (+ /stages*) | LeadPipelinesController | Bearer + staff + lead_management |
| GET/POST | /organizations/:orgId/conversations | list / create | Bearer + staff + lead_management |
| GET | /organizations/:orgId/conversations/:id | get (with messages) | same |
| POST | /organizations/:orgId/conversations/:id/messages | sendMessage | same |
| PATCH | /organizations/:orgId/conversations/:id/assign | /status | assign / updateStatus | same |
| POST | /organizations/:orgId/conversations/:id/read | markRead | same |
| POST | /webhooks/email/inbound | EmailInboundController.inbound | @Public + EMAIL_INBOUND_SECRET + @Throttle |
Import paths (two, by consumer)
Leads can be imported two ways — pick by who’s calling:
- Bulk JSON (agent / API) —
POST /organizations/:orgId/leads/import→OrganizationLeadsService.importLeads. Synchronous, structuredrows[], batch + existing dedup, per-row results. Used by the Spotter agent (leads.importtool) and any API client. No file upload. - CSV (human, member-parity) — the generic import framework in
apps/api/src/importwithentity=leads: upload → column-map → confirm → async BullMQimport-leadfan-out, tracked inimport_jobs. Same UX as the members CSV importer. Seedocs/features/exports-imports/. Frontend reusesimport-csv-dialog.tsxvia itsentity="leads"prop, mounted from the leads dashboard.
Web
| Component / Route | Description |
|---|---|
apps/web/src/app/[lang]/(protected)/dashboard/leads/[[...view]]/page.tsx + leads-content.tsx + leads-page.driver.tsx | Route-driven leads surface: board per pipeline (/leads/<pipelineId>), list, inbox (see behavior.md → Routes). Registers the active pipeline + open lead into Spotter’s page context via useAgentPageSelection. |
apps/web/src/components/members/import-csv-dialog.tsx | Shared CSV import dialog; entity="leads" drives the lead variant. |
apps/web/src/components/leads/add-lead-dialog.tsx | Manual create. |
apps/web/src/components/leads/convert-lead-dialog.tsx | Convert UI; calls POST /leads/:id/convert. |
apps/web/src/components/leads/lead-detail-sheet.tsx | Side sheet: detail + status events + edit form + contact CTAs (email/call/WhatsApp) + open-conversation deep link. |
apps/web/src/components/leads/leads-board.tsx | Kanban board (dnd-kit). Columns = pipeline stages; optimistic move via the query cache; drag-to-converted → Convert dialog, drag-to-trial → book-trial dialog. |
apps/web/src/components/leads/pipeline-editor-sheet.tsx | Create/rename/recolor/reorder/archive stages + pipelines. |
apps/web/src/components/leads/email-compose-dialog.tsx | Compose the first email to a lead (starts a conversation). |
apps/web/src/components/leads/book-trial-dialog.tsx | Date prompt when dragging a lead into a trial-booked stage. |
apps/web/src/components/inbox/inbox-panel.tsx | Two-pane inbox (list + thread); single-pane swap on mobile. Rendered inside the Leads tab. |
apps/web/src/components/inbox/conversation-list.tsx + conversation-thread.tsx | Thread list + message thread with composer. |
apps/web/src/components/settings/meta-lead-ads-connect.tsx | Settings → Integrations card: connect Facebook Pages (OAuth + page-picker). |
apps/web/src/components/settings/whatsapp-connect.tsx + fb-embedded-signup.ts | Settings → Integrations card: connect a WhatsApp number (Embedded Signup popup). |
i18n: leads.* + settings.integrations.* in libs/shared/src/lib/i18n/dictionaries/. |
DB tables
| Table | Used as |
|---|---|
leads | Canonical lead row. |
platform_leads | Waitlist tag for platform-marketing leads. |
organization_leads | Per-org link + assignment + trial date + converted membership. |
lead_status_events | Append-only history. |
memberships | Created by convertLead; source_lead_id set. |
users | Find-or-create shell on convert. |
tasks | Auto-task on new lead. |
integration_connections | Per-org Meta Page / WhatsApp number connection; webhook routing key. |
lead_campaigns | Hook bucket (ad/form/campaign) for ingested leads. |
lead_attribution | 1:1 raw attribution per ingested lead. |
lead_pipelines / lead_pipeline_stages | Configurable per-org funnel; stage category mirrors leads.status. |
conversations / conversation_messages | Unified inbox threads + messages (email/manual today). |
Shared schemas
libs/shared/src/lib/schemas/lead.schema.ts exports the Zod schemas used by controllers:
createPlatformLeadSchema— name/email/phone/locale/source/note.createOrganizationLeadSchema— same; source defaults to'manual'(public minisite path overrides to'minisite').updateOrganizationLeadSchema— name/email/phone/note/status/assignedToUserId/trialDate.listOrganizationLeadsSchema— search/status/source/page/limit query params.convertLeadSchema— firstName/lastName/email/role.importOrganizationLeadsSchema—rows[](each name/email/phone/locale/source/note) +defaultSource+skipDuplicates; per-row validation so one bad row never rejects the batch. ReturnsimportLeadsResultSchema(total/imported/skipped/failed+ per-rowresults).
Enums: leadSource, leadStatus (see libs/db/src/lib/schema/enums.ts).
libs/shared/src/lib/schemas/lead-pipeline.schema.ts — pipeline/stage request + response schemas, leadStageCategory (= leadStatus), DEFAULT_LEAD_STAGE_NAMES (single source for the backend seed + the board’s “is this an un-renamed default?” check), and SYSTEM_LEAD_STAGE_NAMES (same pattern for the funnel’s auto-created “Awaiting payment” / “Didn’t finish signing up” / “Win-back” stages).
libs/shared/src/lib/schemas/conversation.schema.ts — listConversationsSchema, createConversationSchema, sendMessageSchema, assignConversationSchema, updateConversationStatusSchema, the channel/status/direction enums, and the list/detail response types.
Agent (Spotter) surface
apps/api/src/ai/agent/tools/leaves/leads.tools.ts exposes the leads router to the Spotter agent (registered in ai.module.ts, schemas in libs/shared/src/lib/agent-schemas/leads.ts):
| Action | Side effect | Confirm | Roles | Backing service method |
|---|---|---|---|---|
list | read | never | staff | listLeads |
get | read | never | staff | getLeadById |
create | write | never | staff | createLead |
update | write | never | staff | updateLead |
convert | write | always | owner/admin | convertLead |
import | write | always | staff | importLeads |
analytics | read | never | staff | getAnalytics |
Tool-call labels: agent.tools.leads.* in libs/shared/src/lib/i18n/dictionaries/{en,he,ru}.json.
Note: bulk
importintentionally does not fire the per-lead auto-task (unlike singlecreateLead) to avoid flooding the owner’s task queue.
Events
| Event | Producer | Consumers |
|---|---|---|
MEMBERSHIP_ACTIVATED { source:'lead_converted' } | convertLead | Forms fan-out (compliance forms), analytics, future welcome emails (per code comments). |
LEAD_CREATED / LEAD_STATUS_CHANGED | createLead / moveLeadToStage | automations/automation-events.listener.ts (lead-funnel automations). |
conversation.inbound_received | recordInbound | None yet — the decoupled hook for inbound notification delivery (push/email). Logged for observability today. |
MEMBERSHIP_RELEASED_UNPAID | PaymentMonitoringService (join-link release sweeps) | demoteReleasedJoinMembership — opens or reopens the lead. Emitted with emitAsync and awaited, so a sweep’s releases reach the CRM one at a time instead of racing each other on the same lead row. |
Tests
| File | What it covers |
|---|---|
apps/web/src/app/[lang]/(protected)/dashboard/leads/leads-page.driver.tsx | Web driver for leads page (driver pattern — see CLAUDE.md). |
apps/api/src/conversations/email-flow.int.spec.ts | End-to-end email round-trip: outbound, inbound threading, idempotency, raw-MIME extraction, auto new→contacted, In-Reply-To headers, secret rejection, mark-read. |
apps/api/src/organization-leads/organization-leads.int.spec.ts | The converted-only-via-convert invariant + an ordinary status move. |
apps/api/src/organization-leads/lead-identity.unit.spec.ts | The “same person” rule (isSameLeadPerson): email conclusive, phone only with an agreeing name and an unshared number. |
apps/api/src/organization-leads/lead-identity.int.spec.ts | Against real SQL: one lead per person across two submissions, a second person on the family phone getting their own, crm-lead-identity OFF still collapsing them, a new note sentence without a phantom transition, and two simultaneous releases losing nothing. |
apps/api/src/conversations/channels/{email-mime,email-threading,email.adapter}.unit.spec.ts | MIME parse + quote stripping; Reply-To build/parse; email adapter send/headers/subject. |
apps/api/src/lead-pipelines/lead-pipelines.service.unit.spec.ts | pickStageForStatus + seed/category mapping. |
apps/web/src/components/inbox/{conversation-list,conversation-thread}.int.spec.tsx | Inbox list + thread (driver pattern). |
apps/web/src/components/leads/{leads-board,pipeline-editor-sheet,email-compose-dialog,book-trial-dialog}.int.spec.tsx | Board render/click, stage editor, compose + book-trial dialogs. |
| Gap | Board drag→convert/trial branching is not unit-tested (dnd-kit is stubbed) — verified manually. listConversations filters + assign-non-member also lack direct specs. |