Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesLeads CrmLeads & CRM — Code Map

Leads & CRM — Code Map

API

Module — leads (public lead intake)

FilePurpose
apps/api/src/leads/leads.module.tsWires LeadsController + LeadsService + cross-import to OrganizationLeadsService.
apps/api/src/leads/leads.controller.tsTwo public endpoints: platform waitlist + minisite contact form.
apps/api/src/leads/leads.service.tsTiny — only handles platform leads (inserts leads + platform_leads).

Module — organization-leads (staff CRM)

FilePurpose
apps/api/src/organization-leads/organization-leads.module.tsWires the controllers + services below (incl. the lead-bulk-email BullMQ queue).
apps/api/src/organization-leads/organization-leads.controller.tsAll staff CRM routes under organizations/:orgId/leads. Decorated with @RequiresFeature('lead_management').
apps/api/src/organization-leads/organization-leads.service.tsList/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.tsAppend/read lead_activity_events (FIT-227). Best-effort writes: history never fails the operation it records.
apps/api/src/organization-leads/lead-purchase.service.tsCourse-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.tsBulk 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.tsSends 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.tsTag 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.

FilePurpose
apps/api/src/lead-ingestion/lead-ingestion.module.tsWires webhook + integration controllers, per-channel services, and the BullMQ queue.
apps/api/src/lead-ingestion/lead-ingestion.processor.tsQueue worker; dispatches by job.name to the channel processor.
apps/api/src/lead-ingestion/meta-*.tsLead Ads: OAuth connect + page-picker, leadgen webhook, Graph fetch, field mapping, processor.
apps/api/src/lead-ingestion/whatsapp-*.tsWhatsApp: Embedded Signup connect, CTWA messages webhook, processor.
apps/api/src/lead-ingestion/meta-signature.util.tsShared X-Hub-Signature-256 HMAC (both channels).

Module — lead-pipelines (configurable funnels)

FilePurpose
apps/api/src/lead-pipelines/lead-pipelines.module.tsWires controller + service.
apps/api/src/lead-pipelines/lead-pipelines.controller.tsPipeline + stage CRUD under organizations/:orgId/lead-pipelines. @RequiresFeature('lead_management').
apps/api/src/lead-pipelines/lead-pipelines.service.tsensureDefaultPipeline (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)

FilePurpose
apps/api/src/conversations/conversations.module.tsWires controller + inbound webhook controller + service + the channel adapters/registry.
apps/api/src/conversations/conversations.service.tsList/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.tsStaff 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.tsCONVERSATION_INBOUND_RECEIVED event + payload type.
apps/api/src/conversations/channels/channel-adapter.tsChannelAdapter interface + ChannelContact.
apps/api/src/conversations/channels/channel-registry.service.tsResolves an adapter by channel.
apps/api/src/conversations/channels/{manual,email}.adapter.tsThe two live adapters (manual note; email via Resend + localized fallback subject).
apps/api/src/conversations/channels/email-mime.tsZero-dep MIME parse (extractEmailText, charset-aware) + stripQuotedReply.
apps/api/src/conversations/channels/email-threading.tsReply-To address build + parse for both the 1:1 (buildReplyToAddress/parseConversationId) and broadcast (buildOrgLeadReplyToAddress/parseOrgLeadId) schemes.

Routes

MethodPathHandlerAuth
POST/leadsLeadsController.createPlatformLead@Public
POST/leads/organization/:orgIdLeadsController.createMinisiteLead@Public
GET/organizations/:orgId/leads/analyticsOrganizationLeadsController.getAnalyticsBearer + staff + lead_management
GET/organizations/:orgId/leadslistLeadssame
GET/organizations/:orgId/leads/:leadIdgetLeadsame
POST/organizations/:orgId/leadscreateLeadsame
POST/organizations/:orgId/leads/importimportLeads (bulk)Bearer + staff + lead_management
POST/organizations/:orgId/leads/bulk-emailsendBulkEmailsame
POST/organizations/:orgId/leads/bulk-assign-pipelinebulkAddLeadsToPipelinesame
POST/organizations/:orgId/leads/bulk-move-stagebulkMoveLeadsToStagesame
POST/organizations/:orgId/leads/bulk-tagsbulkUpdateLeadTagssame
GET/POST/organizations/:orgId/leads/:leadId/pipelineslistLeadPipelines / addLeadToPipelinesame
PATCH/DELETE/organizations/:orgId/leads/:leadId/pipelines/:pipelineIdmoveLeadInPipeline / removeLeadFromPipelinesame
POST/organizations/:orgId/leads/:leadId/pipelines/:pipelineId/set-primarysetPrimaryPipelinesame
PATCH/organizations/:orgId/leads/:leadIdupdateLeadsame
POST/organizations/:orgId/leads/:leadId/convertconvertLeadBearer + owner/admin + lead_management
GET/organizations/:orgId/leads/:leadId/link-candidateslistLinkCandidatesBearer + owner/admin + lead_management
POST/organizations/:orgId/leads/:leadId/link-memberlinkLeadToMemberBearer + owner/admin + lead_management
GET/POST/PATCH/organizations/:orgId/lead-pipelines* (+ /stages*)LeadPipelinesControllerBearer + staff + lead_management
GET/POST/organizations/:orgId/conversationslist / createBearer + staff + lead_management
GET/organizations/:orgId/conversations/:idget (with messages)same
POST/organizations/:orgId/conversations/:id/messagessendMessagesame
PATCH/organizations/:orgId/conversations/:id/assign | /statusassign / updateStatussame
POST/organizations/:orgId/conversations/:id/readmarkReadsame
POST/webhooks/email/inboundEmailInboundController.inbound@Public + EMAIL_INBOUND_SECRET + @Throttle

Import paths (two, by consumer)

Leads can be imported two ways — pick by who’s calling:

  1. Bulk JSON (agent / API)POST /organizations/:orgId/leads/importOrganizationLeadsService.importLeads. Synchronous, structured rows[], batch + existing dedup, per-row results. Used by the Spotter agent (leads.import tool) and any API client. No file upload.
  2. CSV (human, member-parity) — the generic import framework in apps/api/src/import with entity=leads: upload → column-map → confirm → async BullMQ import-lead fan-out, tracked in import_jobs. Same UX as the members CSV importer. See docs/features/exports-imports/. Frontend reuses import-csv-dialog.tsx via its entity="leads" prop, mounted from the leads dashboard.

Web

Component / RouteDescription
apps/web/src/app/[lang]/(protected)/dashboard/leads/[[...view]]/page.tsx + leads-content.tsx + leads-page.driver.tsxRoute-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.tsxShared CSV import dialog; entity="leads" drives the lead variant.
apps/web/src/components/leads/add-lead-dialog.tsxManual create.
apps/web/src/components/leads/convert-lead-dialog.tsxConvert UI; calls POST /leads/:id/convert.
apps/web/src/components/leads/lead-detail-sheet.tsxSide sheet: detail + status events + edit form + contact CTAs (email/call/WhatsApp) + open-conversation deep link.
apps/web/src/components/leads/leads-board.tsxKanban 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.tsxCreate/rename/recolor/reorder/archive stages + pipelines.
apps/web/src/components/leads/email-compose-dialog.tsxCompose the first email to a lead (starts a conversation).
apps/web/src/components/leads/book-trial-dialog.tsxDate prompt when dragging a lead into a trial-booked stage.
apps/web/src/components/inbox/inbox-panel.tsxTwo-pane inbox (list + thread); single-pane swap on mobile. Rendered inside the Leads tab.
apps/web/src/components/inbox/conversation-list.tsx + conversation-thread.tsxThread list + message thread with composer.
apps/web/src/components/settings/meta-lead-ads-connect.tsxSettings → Integrations card: connect Facebook Pages (OAuth + page-picker).
apps/web/src/components/settings/whatsapp-connect.tsx + fb-embedded-signup.tsSettings → Integrations card: connect a WhatsApp number (Embedded Signup popup).
i18n: leads.* + settings.integrations.* in libs/shared/src/lib/i18n/dictionaries/.

DB tables

TableUsed as
leadsCanonical lead row.
platform_leadsWaitlist tag for platform-marketing leads.
organization_leadsPer-org link + assignment + trial date + converted membership.
lead_status_eventsAppend-only history.
membershipsCreated by convertLead; source_lead_id set.
usersFind-or-create shell on convert.
tasksAuto-task on new lead.
integration_connectionsPer-org Meta Page / WhatsApp number connection; webhook routing key.
lead_campaignsHook bucket (ad/form/campaign) for ingested leads.
lead_attribution1:1 raw attribution per ingested lead.
lead_pipelines / lead_pipeline_stagesConfigurable per-org funnel; stage category mirrors leads.status.
conversations / conversation_messagesUnified 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.
  • importOrganizationLeadsSchemarows[] (each name/email/phone/locale/source/note) + defaultSource + skipDuplicates; per-row validation so one bad row never rejects the batch. Returns importLeadsResultSchema (total/imported/skipped/failed + per-row results).

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.tslistConversationsSchema, 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):

ActionSide effectConfirmRolesBacking service method
listreadneverstafflistLeads
getreadneverstaffgetLeadById
createwriteneverstaffcreateLead
updatewriteneverstaffupdateLead
convertwritealwaysowner/adminconvertLead
importwritealwaysstaffimportLeads
analyticsreadneverstaffgetAnalytics

Tool-call labels: agent.tools.leads.* in libs/shared/src/lib/i18n/dictionaries/{en,he,ru}.json.

Note: bulk import intentionally does not fire the per-lead auto-task (unlike single createLead) to avoid flooding the owner’s task queue.

Events

EventProducerConsumers
MEMBERSHIP_ACTIVATED { source:'lead_converted' }convertLeadForms fan-out (compliance forms), analytics, future welcome emails (per code comments).
LEAD_CREATED / LEAD_STATUS_CHANGEDcreateLead / moveLeadToStageautomations/automation-events.listener.ts (lead-funnel automations).
conversation.inbound_receivedrecordInboundNone yet — the decoupled hook for inbound notification delivery (push/email). Logged for observability today.
MEMBERSHIP_RELEASED_UNPAIDPaymentMonitoringService (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

FileWhat it covers
apps/web/src/app/[lang]/(protected)/dashboard/leads/leads-page.driver.tsxWeb driver for leads page (driver pattern — see CLAUDE.md).
apps/api/src/conversations/email-flow.int.spec.tsEnd-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.tsThe converted-only-via-convert invariant + an ordinary status move.
apps/api/src/organization-leads/lead-identity.unit.spec.tsThe “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.tsAgainst 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.tsMIME parse + quote stripping; Reply-To build/parse; email adapter send/headers/subject.
apps/api/src/lead-pipelines/lead-pipelines.service.unit.spec.tspickStageForStatus + seed/category mapping.
apps/web/src/components/inbox/{conversation-list,conversation-thread}.int.spec.tsxInbox list + thread (driver pattern).
apps/web/src/components/leads/{leads-board,pipeline-editor-sheet,email-compose-dialog,book-trial-dialog}.int.spec.tsxBoard render/click, stage editor, compose + book-trial dialogs.
GapBoard drag→convert/trial branching is not unit-tested (dnd-kit is stubbed) — verified manually. listConversations filters + assign-non-member also lack direct specs.