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, and reopenReturningLead / reopenLead for a closed lead that comes back.
apps/api/src/organization-leads/lead-return.tsisReopenableLead + 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.tsAppend/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.tsTKN-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.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-candidates (?search= → staff roster search)listLinkCandidatesBearer + 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/import → OrganizationLeadsService.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 Sales workspace.

Web

Component / RouteDescription
apps/web/src/app/[lang]/(protected)/dashboard/leads/[[...view]]/page.tsx + sales-route.tsxThe 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.tsxShared CSV import dialog; entity="leads" drives the lead variant.
apps/web/src/components/leads/add-lead-dialog.tsxManual 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.tsxThe 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.tsxConvert / 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.tsxCreate/rename/recolor/reorder/archive stages + pipelines (Sales → Manage).
apps/web/src/components/leads/sales-attention-list.tsxThe overview’s “needs attention” rows, from the leads.needs_action insight.
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.
  • importOrganizationLeadsSchema — rows[] (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.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):

ActionSide effectConfirmRolesBacking service method
listreadneverstafflistLeads
getreadneverstaffgetLeadById
createwriteneverstaffcreateLead
updatewriteneverstaffupdateLead
convertwritealwaysowner/adminconvertLead
importwritealwaysstaffimportLeads
analyticsreadneverstaffgetAnalytics
pipelines_listreadneverstaffLeadPipelinesService.listPipelines (+ listLeadPipelines)
campaigns_listreadneverstafflistCampaigns
campaign_createwriteneverowner/admincreateCampaign
add_to_pipelinewriteneverstaffaddLeadToPipeline
move_stagewriteneverstaffmoveLeadInPipeline
tags_listreadneverstaffLeadTagsService.list
set_tagswriteneverstaffLeadTagsService.setForLead
set_follow_upwriteneverowner/adminsetFollowUp / clearFollowUp (nextActionAt: null)
archivewriteneverstaffarchiveLead (inverse restore)
restorewriteneverstaffrestoreLead
reopenwriteneverstaffreopenLead → reopenReturningLead (lost / frozen / archived only)
bulk_move_stage_previewreadneverowner/adminpreviewBulkMoveLeadsToStage
bulk_move_stagewritealwaysowner/adminbulkMoveLeadsToStage
bulk_add_to_pipelinewritealwaysowner/adminbulkAddLeadsToPipeline
bulk_set_tagswritealwaysowner/adminLeadTagsService.bulkSetForLeads
bulk_archivewritealwaysowner/adminbulkArchiveLeads
bulk_emailwritealwaysowner/adminLeadBulkEmailService.sendBulk (consent-filtered)
pipeline_createwriteneverowner/adminLeadPipelinesService.createPipeline
pipeline_updatewriteneverowner/adminLeadPipelinesService.updatePipeline
stage_addwriteneverowner/adminLeadPipelinesService.addStage
stage_updatewriteneverowner/adminLeadPipelinesService.updateStage
stage_removewritedestructiveowner/adminLeadPipelinesService.removeStage
stages_reorderwritealwaysowner/adminLeadPipelinesService.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 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/sales-*.int.spec.tsxThe Sales workspace: route + legacy redirects, queue, lead sheet, board, conversation, manage, reports (driver pattern).
apps/api/src/conversations/email-flow.int.spec.tsEnd-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.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, 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/leads/{add-lead-dialog,convert-lead-dialog,pipeline-editor-sheet,bulk-email-dialog}.int.spec.tsxCreate/edit, convert + link, stage editor, bulk email.
GaplistConversations filters + assign-non-member lack direct specs.