Dentolize · CRM Module Walkthrough
On this pageBusiness viewTechnical view

Lead Capture & Attribution

Business view

This is the heart of the CRM: turning the noise of ad platforms into clean, deduplicated leads that know where they came from. A person who clicks a Meta lead ad, fills a Google form, or sends an Instagram DM should show up in Dentolize exactly once — attached to a pipeline stage, assigned to a team member, and stamped with the campaign that produced them.

Three things make this trustworthy:

  • Nothing gets lost. Every inbound event from a platform is written to a durable

inbox before Dentolize tries to process it. If processing fails — a platform outage, a bad token, a bug — the event is still on disk and is retried automatically. When a platform sends the same event five times (they do), it still becomes exactly one lead.

  • No duplicate patients. Leads are deduplicated by phone number, and phone

matching understands Arabic-Indic and Eastern-Arabic digits (٠١٢٣ and ۰۱۲۳), whitespace, dashes, and right-to-left marks — so +966 50 …, ٩٦٦…, and a copy-pasted variant all resolve to the same person. An existing patient always wins over a lead record.

  • Fair distribution. New leads are handed to the stage's team members in

round-robin order, so no one gets buried and no one is skipped.

Every captured lead carries an attribution trail — the source type (lead form, DM, comment, click-to-WhatsApp, QR link, manual, import…), the platform, and the raw campaign / ad-set / ad ids. That trail is what later lets the Marketing Hub say "this campaign produced 12 leads and 3 paying patients."

Technical view

The durable webhook inbox (verify → persist → enqueue → 200)

All inbound webhook HTTP endpoints live in the gateway (packages/whatsapp-official, NestJS), not the GraphQL server. The pattern is implemented in webhooks/webhook-ingest.service.ts (docstring :25):

  1. Verify — HMAC-SHA256, constant-time compare (utilities/xHubSignatureUtils.ts:9,

verifyXHub using crypto.timingSafeEqual).

  1. Route to tenantresolveCompanyId (webhook-ingest.service.ts:51) maps platform

asset ids → company via ConnectedAsset, Redis-cached (crm:asset-route, 600s). Tenant is always derived from server-held mappings, never from sender-controlled payload fields — a deliberate security property (:46). Unroutable events are stored DEAD, not guessed.

  1. Persist — writes a WebhookEvent row; the @@unique([platform, externalEventId])

constraint (schema.prisma:9051) makes redeliveries idempotent (Prisma P2002 → ignore).

  1. Enqueue — BullMQ social-webhook-ingest queue, 5 attempts, exponential backoff.
  2. Ack fast — the controller returns 200 before/independently of processing.

Meta controller (webhooks/meta-webhook.controller.ts): a GET handshake validates hub.verify_token against FACEBOOK_CALLBACK_TOKEN and echoes the challenge (:30); the POST handler verifies x-hub-signature-256 against FACEBOOK_APP_SECRET (401 on failure, :40), uses sha256(rawBody) as the dedupe id (Meta has no global event id, :55), sends 200 at :59 before ingesting, then ingests one event per entry with a "<object>:<field>" topic such as page:leadgen or instagram:messaging. It also hosts Meta's Data Deletion callback (:84).

Google controller (platforms/google/lead-form.controller.ts): routes by the integration id in the path (not payload), authenticates a shared google_key with a constant-time compare against SocialIntegration.webhookKey (:49), strips the key before storing the payload (:63).

Worker (webhooks/social-webhook.worker.ts, concurrency 10): looks up a handler by "<platform>:<topic>". Crucially, topics with **no registered handler are parked** — left RECEIVED, not retried, not failed (:57) — so enabling a feature later lets the system replay that history. On success → PROCESSED; on retry exhaustion → FAILED/DEAD.

The WebhookEvent model (schema.prisma:9032) has a nullable companyId (:9036, stays null when unroutable), signatureValid, attempts, error, processedAt, and is indexed [status, createdAt] — the last index driving the retention/redaction cron.

The central lead-capture function

LeadCaptureService.capture() (packages/whatsapp-official/src/crm/lead-capture.service.ts:53captureInternal() :66) is "the single write path for machine-captured leads … fully transactional" (:36). Its logic:

  • Idempotency by external lead id — dedupes on LeadCaptureEvent's

@@unique([companyId, platform, externalLeadId]); a repeat returns dedupedAgainst: "EXTERNAL_LEAD_ID" (:67).

  • Phone normalization + dedupenormalizePhone() (crm/phone.util.ts:14) maps

Arabic-Indic / Eastern-Arabic digits (ARABIC_INDIC_DIGITS, :3), strips whitespace, dashes, and RTL marks (:18), and parses with libphonenumber-js{ e164, stored }. The service then looks up an existing Patient by fullNumber, preferring real patients over lead-patients (orderBy type asc, :93). If the matched patient already has a lead, it records nothing new and returns dedupedAgainst: "EXISTING_LEAD_PHONE" (first-touch wins, :101).

  • Round-robin assignment — `assignedTo = stage.users[(stage.totalLeads - 1) %

stage.users.length] (:123`), rotating on the just-incremented stage counter.

  • Inside one $transaction: increments LeadStage.totalLeads and Company.lastLeadId,

creates/connects the Patient, and creates the Lead with a nested captureEvent carrying source / platform / asset / raw attribution ids and fieldData (:151).

  • After commit, emits AutomationTrigger.LEAD_CREATED (:55).

Meta lead-form events reach this function via platforms/meta/leadgen.service.ts (registers META:page:leadgen, :42), which resolves the LEAD_FORM asset for per-form stage/branch mapping, honours the asset's syncLeads toggle, fetches the submission through the Graph with the asset token, and maps fields via crm/lead-field-map.ts.

The attribution spine

LeadCaptureEvent (schema.prisma:9486) is one row per captured lead (leadId @unique → 1:1 with Lead). It records source (LeadCaptureSourceType — 11 values from LEAD_FORM to MANUAL/IMPORT), platform, externalLeadId, formAsset, and the raw external ids externalAdId / externalAdSetId / externalCampaignId (:9497). These are stored as raw strings, not foreign keys, so a lead captured before the campaign hierarchy has been synced still attributes correctly once the hierarchy arrives (:9482). @@unique([companyId, platform, externalLeadId]) plus an index on externalCampaignId for ROI rollups.

Reconciliation — catching what webhooks miss

Webhooks can be dropped during a platform outage. A nightly socialLeadReconcile cron (0 3 * * *, cronJobs.js:149) plus a 5-minute socialLeadPoll (:154) poll the platforms for leads that never arrived by push and feed them through the same capture function. leadReconciliationReport.js surfaces any remaining platform-vs-CRM gap in the Marketing Hub's Reconciliation tab.

Separately, reconcileCrmCounters (03:30 nightly, cronJobs.js:144) recomputes the denormalized counters (LeadStage.totalLeads, RejectReason.totalLeads, LeadLink.registers) against the source rows, heals any drift in chunked transactions, and reports the drift to Sentry — because drift means a write path is missing an increment/decrement.