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, and reopenReturningLead / reopenLead for a closed lead that comes back. |
apps/api/src/organization-leads/lead-return.ts | isReopenableLead + returnLead: the one write path that reopens a lost / frozen / archived lead (stage, status, return marker, call due now, lead_returned entry), shared by intake, the Sales Reopen action and the automated entry points. |
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-follow-up-task.service.ts | TKN-419 follow-up ⇄ task sync: syncFromFollowUp, closeLeadFollowUp, upsertLeadTask, setFollowUpFromTask, closeFollowUpFromTask, lockFollowUp / readFollowUp, ensureAutomationLeadTask, and taskMirrorsFollowUp (does a linked task stand for the follow-up?). The only writer that keeps a lead’s follow-up and its one open contact_lead task in step; never calls back into the leads or tasks service; takes the lead row before the task. Provided by lead-follow-up-tasks.module.ts (imported by organization-leads, tasks and automations without a module cycle). |
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 (?search= → staff roster search) | 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 Sales workspace.
Web
| Component / Route | Description |
|---|---|
apps/web/src/app/[lang]/(protected)/dashboard/leads/[[...view]]/page.tsx + sales-route.tsx | The leads route is the Sales workspace (sales-workspace.tsx and its sales-* siblings): the bare route and its tabs render it, the retired Leads views (list, inbox, pipeline, a bare pipeline id) redirect into it (apps/web/src/lib/sales-workspace-routes.ts → legacyLeadsRedirect; see behavior.md → Routes). |
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 through POST …/sales-workspace/leads; with leadId, edits the lead through PATCH …/leads/:id. A response with intake: 'reopened' says the person was a closed lead that is now back. |
apps/web/src/app/[lang]/(protected)/dashboard/leads/sales-returning-lead.tsx | The History Reopen button (POST …/sales-workspace/leads/:id/reopen, on the row and in the lead sheet) and the queue row’s “Came back … · was … · …” line. |
apps/web/src/components/leads/convert-lead-dialog.tsx | Convert / link UI through …/sales-workspace/leads/:id/{convert,link-member,link-candidates}, versioned against the lead record. |
apps/web/src/components/leads/pipeline-editor-sheet.tsx | Create/rename/recolor/reorder/archive stages + pipelines (Sales → Manage). |
apps/web/src/components/leads/sales-attention-list.tsx | The overview’s “needs attention” rows, from the leads.needs_action insight. |
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 (29 actions) 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 |
pipelines_list | read | never | staff | LeadPipelinesService.listPipelines (+ listLeadPipelines) |
campaigns_list | read | never | staff | listCampaigns |
campaign_create | write | never | owner/admin | createCampaign |
add_to_pipeline | write | never | staff | addLeadToPipeline |
move_stage | write | never | staff | moveLeadInPipeline |
tags_list | read | never | staff | LeadTagsService.list |
set_tags | write | never | staff | LeadTagsService.setForLead |
set_follow_up | write | never | owner/admin | setFollowUp / clearFollowUp (nextActionAt: null) |
archive | write | never | staff | archiveLead (inverse restore) |
restore | write | never | staff | restoreLead |
reopen | write | never | staff | reopenLead → reopenReturningLead (lost / frozen / archived only) |
bulk_move_stage_preview | read | never | owner/admin | previewBulkMoveLeadsToStage |
bulk_move_stage | write | always | owner/admin | bulkMoveLeadsToStage |
bulk_add_to_pipeline | write | always | owner/admin | bulkAddLeadsToPipeline |
bulk_set_tags | write | always | owner/admin | LeadTagsService.bulkSetForLeads |
bulk_archive | write | always | owner/admin | bulkArchiveLeads |
bulk_email | write | always | owner/admin | LeadBulkEmailService.sendBulk (consent-filtered) |
pipeline_create | write | never | owner/admin | LeadPipelinesService.createPipeline |
pipeline_update | write | never | owner/admin | LeadPipelinesService.updatePipeline |
stage_add | write | never | owner/admin | LeadPipelinesService.addStage |
stage_update | write | never | owner/admin | LeadPipelinesService.updateStage |
stage_remove | write | destructive | owner/admin | LeadPipelinesService.removeStage |
stages_reorder | write | always | owner/admin | LeadPipelinesService.reorderStages |
The TKN-305 bulk / pipeline-administration rows advertise owner/admin (the leads / leadPipelines permission-matrix rows the services enforce) and re-apply the lead_management tier gate through ToolFeatureGate. The CRM inbox is its own inbox router (apps/api/src/ai/agent/tools/leaves/inbox.tools.ts, schemas in agent-schemas/inbox.ts): list, get, start, send, assign, set_status, mark_read — sends always confirm and go through ConversationsService.sendMessage, so the ADR-0013 consent gate and markContacted apply unchanged.
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/sales-*.int.spec.tsx | The Sales workspace: route + legacy redirects, queue, lead sheet, board, conversation, manage, reports (driver pattern). |
apps/api/src/conversations/email-flow.int.spec.ts | End-to-end email round-trip on member threads: outbound, inbound threading, idempotency, raw-MIME extraction, In-Reply-To headers, secret rejection, mark-read; lead threads stay off the member desk, and a broadcast reply opens the lead’s thread and moves it new→contacted. |
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, 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/leads/{add-lead-dialog,convert-lead-dialog,pipeline-editor-sheet,bulk-email-dialog}.int.spec.tsx | Create/edit, convert + link, stage editor, bulk email. |
| Gap | listConversations filters + assign-non-member lack direct specs. |