Skip to content

CRM Lead Data Model — Where Every Panel's Data Lives ​

A map of every panel on the lead detail page and the dashboard "CRM Activity" / "Lead stages" widgets to the exact Mongo collection and field that backs it, so you never have to guess "where is X stored" again.

Why read this: the lead page looks like one screen, but it is assembled from nine collections plus several values that are computed on the fly (never stored). Mixing these up leads to bugs like querying the wrong collection for call counts, or expecting a stored "last contacted" that is actually derived.

The one-paragraph mental model ​

A lead is one CrmLead document. Its own attributes (status, temperature, tags, email, assignment, custom-field values, marketing attribution) live on that document. Everything else hanging off the lead is a separate collection joined by the leadId string: calls (Contact), non-call messages/notes (CrmCommunicationLog), AI engagement/stage (CrmLeadJourney), deals (CrmOpportunity), to-do tasks (CrmTask), time-based reminders/scheduled calls (CrmReminder), and website visits (CrmWebSession + CrmWebEvent). Custom-field definitions (not values) live in CrmCustomField.

Architecture Overview ​

Master table — UI element → storage ​

Legend: Stored = a real field on a document · Derived = computed client-side (not stored) · External = written by another system (AI engine / tracking snippet).

UI elementRendered byCollection + model fileField(s)Kind
Header Engagement chipLeadProfileOverview.tsxCrmLeadJourney (models/CrmLeadJourney.ts), via lead.journeyjourney.engagementScoreExternal (AI)
Header Interactions (N, N calls · Xs)LeadProfileOverview.tsxmerged timeline (Contact + CrmCommunicationLog)communications.length, stats.totalCalls, summed durationDerived
Header Success Rate (X%, X/Y calls)LeadProfileOverview.tsxContact (call records)from call outcome (completed = connected)Derived
Header Stage chipLeadProfileOverview.tsxCrmLeadJourney via lead.journeyjourney.buyingStageExternal (AI)
Status pill + temperature pill (under name)LeadProfileOverview.tsxCrmLead (models/CrmLead.ts)status, leadTemperatureStored
Activity by channel (Calls/WhatsApp/SMS/Email/Notes counts)LeadProfileOverview.tsxCalls → Contact; rest → CrmCommunicationLogcount of timeline rows by type (+ isWhatsAppEntry)Derived
Key Facts → StatusLeadDetailPage.tsxCrmLeadstatusStored
Key Facts → TagsLeadDetailPage.tsxCrmLeadtags: string[]Stored
Details & attribution → Status / Email / CreatedLeadProfileOverview.tsxCrmLeadstatus, email, createdAtStored
Details & attribution → Assigned toLeadProfileOverview.tsxCrmLeadassignedUserUIDs[] (fallback assignedToUID); name resolved from org usersStored (id) / Derived (name)
Details & attribution → ChannelLeadProfileOverview.tsx (classifyLead)CrmLeadclassification.channel (persisted) ?? derived from metadata.source + sourceHistory[]; platform sub-detail from metadata.platform/metaPlatformStored + Derived (see Note B)
Details & attribution → SourceLeadProfileOverview.tsx (classifyLead)CrmLeadclassification.source (persisted) ?? derived (Meta platform → utm → referrer=Organic → Direct)Stored + Derived (see Note B)
Details & attribution → Last contactLeadDetailPage.tsx (effectiveLastContactedAt)merged timeline, fallback CrmLead.lastContactedAtnewest timeline timestampDerived (see Note C)
Marketing Attribution panel (First touch / Latest touch / Source / Tracking IDs)LeadAttributionCard.tsx + lib/leadOrigin.tsCrmLeadattribution.{firstTouch,lastTouch,identifiers}; Acquisition block from metadata.meta* / sourceHistory[].ref / industryData.meta_*Stored (attribution) + Derived (Acquisition via getLeadOrigin)
Custom Fields panel (work hours, course, Meeting Time, etc.)LeadCustomFieldsCard.tsxdefs → CrmCustomField; values → CrmLeaddefs: fieldKey/displayName/origin; values: CrmLead.industryData[fieldKey]Stored (two collections; Note D)
Conversation Timeline + tabs (All/WhatsApp/Call/Activity/Meeting Note)LeadDetailPage.tsxCalls → Contact; rest → CrmCommunicationLogmerged rows; filter on type + isWhatsAppEntry + metadata.kindStored rows / Derived merge (Note A)
Website Activity panelLeadActivityPanel.tsx → GET /api/leads/:leadId/activityCrmWebSession + CrmWebEventsession sessionId/source/startedAt; event eventName/eventTime/page; keyed by leadIdStored (implemented; Note E)
Opportunities panel (deals)LeadOpportunitiesPanel.tsxCrmOpportunity (models/CrmOpportunity.ts)opportunityId, contactId (= leadId), name, amount, stage, expectedCloseDate, isWon/isClosedStored
Apollo enrichment panelLeadApolloEnrichmentCard.tsxCrmLeadindustryData.{company,title,seniority,department,industry,linkedin_url,employee_count}; metadata.source="apollo"/metadata.apolloIdStored (lands on the lead; Note F)
Tasks & reminders panelLeadTasksPanel.tsxCrmTask (models/CrmTask.ts) — separate from CrmRemindertaskId, leadId, title, description, status (open/done), priority (low/medium/high), dueDate, assignedToUIDStored (Note G)
Activity tab → Set Reminder / Schedule CallSetReminderModal.tsxCrmReminder (models/CrmReminder.ts)reminderId, leadId, remindAt, note, kind (reminder/scheduled_call), statusStored (Note G)
Meeting Note tab → meeting timeLeadDetailPage.tsxCrmCommunicationLog (type=comment, metadata.kind="meeting_note") + CrmLead common fieldslog metadata.meetingTime/meetingLocation/callBackTime; lead industryData.meeting_time/meeting_location/call_back_timeStored (Note H)
Dashboard CRM Activity → Upcoming MeetingsDashboardPage.tsxCrmReminder via GET /api/reminders?status=pendingpending reminders, kind === "scheduled_call", future remindAtDerived split (Note G)
Dashboard CRM Activity → Follow-upsDashboardPage.tsxCrmReminderpending reminders, kind !== "scheduled_call", future remindAtDerived split (Note G)
Dashboard Overview → Leads (Total/Contacted/Converted/Lost)DashboardPage.tsxCrmLead via GET /api/leads/statscounts over grouped CrmLead.statusDerived rollup (Note I)
Dashboard Overview → CampaignsDashboardPage.tsxCampaign collection via campaignService.list()isRunning + stats.{in_progress,pending,total}Derived
Dashboard Lead stages bar listDashboardPage.tsx → dashboardLeadStages.tsCrmLead via GET /api/leads/statsbyStatus (grouped status); names from statusOptionsDerived (Note I)

Notes (where nuance matters) ​

A — The Conversation Timeline merges TWO collections ​

getCommunicationsByLead (controllers/crmCommunication.controller.ts) merges chronologically: calls from Contact (the single source of truth — mapped by utils/crmCallTimeline.utils.ts) and everything else from CrmCommunicationLog (comment, email, sms, status_change, transaction), filtering out legacy type:"call" log rows so calls aren't double-counted. The tab filter runs on type + isWhatsAppEntry (WhatsApp is an sms-type row, not its own type) + metadata.kind (meeting note vs reminder). Because of this merge, the header Interactions / Success Rate and Activity-by-channel counts are computed in a stats memo on the client — there is no stored interaction counter.

B — Normalized Channel/Source vs structured attribution Source ​

Since the lead-classification feature, the Details & attribution → Channel and Source rows read a single normalized classification via classifyLead(lead) (lib/leadOrigin.ts), which prefers the persisted CrmLead.classification and falls back to deriving it for legacy leads. The derivation folds the raw signals (metadata.source, metadata.platform/metaPlatform, utm*, attribution.*, sourceHistory[]) into:

  • classification.channel — one of the closed 11-channel set (Website, Facebook, WhatsApp, Offline, Email, Outbound call, Inbound, Event, Affiliate, Online Other, Offline Other). The platform sub-detail (Instagram/Facebook) is shown in parentheses for Meta leads.
  • classification.source — a humanized value, derived in strict order: Meta leads → the ad platform (Instagram/Facebook); else utm_source; else an external referrer → Organic; else Direct. Never empty.

The backend computes this ONCE at the lead-insert seam (CrmLeadRepository.createWithOutcome) and persists it; transformLead returns classification (persisted ?? derived) so older leads are classified on read. The same table is mirrored on the backend in utils/leadClassification.utils.ts and the frontend in lib/leadOrigin.ts (classifyLead).

Distinguish these three "source"-looking values:

  • Details & attribution → Channel / Source = the normalized classification (above). Stored (persisted), with a client-side derived fallback.
  • Marketing Attribution → Source = the structured CrmLead.attribution.*.source (raw, e.g. facebook_ad), rendered by LeadAttributionCard.tsx. Stored.
  • getLeadOrigin (lib/leadOrigin.ts) still powers only the Acquisition sub-block (Page/Form/Campaign/Ad set/Ad/Platform).

C — "Last contact" is derived, with a known gap ​

effectiveLastContactedAt takes the newest timeline timestamp and only falls back to the stored CrmLead.lastContactedAt. The stored field exists but is not written on every interaction (a known backend gap), so reports that read it directly can undercount. Prefer the derived value, or fix the write path.

D — Custom fields: definitions vs values, common vs user-created ​

Definitions live in CrmCustomField (fieldKey, displayName, fieldType, origin: "system" | "user"). Values live on CrmLead.industryData[fieldKey] (fallback columns). Of the fields on the page, summary, call_back_time, meeting_time, meeting_location, location are COMMON_FIELDS (seeded for every org); the rest (work hours, course, "Raw budget34", Product/Service Interest, Channel) are user-created / industry-template fields. Values with no matching definition render under "Additional details" (orphaned keys — e.g. raw Meta ad answers).

E — Website Activity is implemented, not a placeholder ​

GET /api/leads/:leadId/activity → CrmLeadActivityService.getForLead reads CrmWebSession + CrmWebEvent. Sessions start anonymous (by visitorId) and are stitched onto the lead by IdentityStitchingService once identified. The "Install the tracking snippet" empty state shows only when a lead has no stitched sessions — common, because it requires the tracking snippet to be installed.

F — Apollo enrichment lands on the lead, not a separate collection ​

apolloConnecterService.enrichLead(leadId) fills missing fields onto CrmLead (industryData + core fields) and stamps metadata.source = "apollo" / metadata.apolloId. The connection status is a CrmProviderConnection-style record, but the enriched data lives on the lead.

G — Two separate "task-like" systems (the common confusion) ​

CrmTaskCrmReminder
Model filemodels/CrmTask.tsmodels/CrmReminder.ts
Shown in"Tasks & reminders" panel + the Tasks nav/pageLead Activity tab + dashboard CRM Activity (Upcoming Meetings / Follow-ups)
Shapeassignable to-do: title, priority (low/medium/high), dueDate, status (open/done)time-based nudge: remindAt, note, kind (reminder/scheduled_call), status (pending/notified/done/dismissed)
Dashboard link"Tasks" overview"Upcoming Meetings" = kind:"scheduled_call"; "Follow-ups" = kind:"reminder" (split client-side on pending + future remindAt)

Despite the heading, the "Tasks & reminders" panel lists CrmTask only — it does not merge in CrmReminder. The dashboard's "Upcoming Meetings"/"Follow-ups" read CrmReminder only.

H — Meeting-Note time ≠ dashboard "Upcoming Meetings" ​

A meeting time typed in the Meeting Note tab is stored as free-text in CrmCommunicationLog.metadata and as the common custom-field value meeting_time on the lead — it is not a CrmReminder and does not appear in the dashboard "Upcoming Meetings" widget (which reads only CrmReminder with kind:"scheduled_call"). The empty-state copy "Book one from a lead's Call tab" refers to the Schedule Call action in the Activity tab.

I — Dashboard counts are status rollups ​

GET /api/leads/stats returns byStatus (grouped CrmLead.status). The Overview "Leads" tile buckets it (converted = won + closed_won, lost = lost + closed_lost, contacted = total − new). The "Lead stages" chart renders one bar per status; stage names/order come from statusOptions = BUILT_IN_LEAD_STATUSES ∪ the org's customStatuses (so "Lead Opp" is a custom status). Nothing extra is stored for the chart.

Collection inventory ​

CollectionModel fileRole on this page
CrmLeadmodels/CrmLead.tsThe lead record: status, temperature, tags, email, assignment, opportunityValue, industryData/columns (custom-field + Apollo values), metadata, attribution, sourceHistory, classification (normalized channel/source/platform), createdAt, lastContactedAt
Contacttelephony call modelCalls — timeline rows + call stats (single source of truth)
CrmCommunicationLogmodels/CrmCommunicationLog.tsNon-call timeline: comments/meeting notes, email, SMS/WhatsApp, status changes, transactions
CrmLeadJourneymodels/CrmLeadJourney.tsAI read-model → Engagement + Stage chips (external; null until the journey engine runs)
CrmOpportunitymodels/CrmOpportunity.tsDeals (Opportunities panel), joined by contactId = leadId
CrmTaskmodels/CrmTask.tsTasks panel + Tasks nav
CrmRemindermodels/CrmReminder.tsActivity-tab reminders/scheduled calls + dashboard CRM Activity
CrmWebSession + CrmWebEventmodels/CrmWebSession.ts, models/CrmWebEvent.tsWebsite Activity panel
CrmCustomFieldmodels/CrmCustomField.tsCustom-field DEFINITIONS (incl. COMMON_FIELDS); values live on CrmLead

How to Test (verify the mapping) ​

  1. Open a lead with calls and notes → the Conversation Timeline shows both; confirm calls exist in Contact (not CrmCommunicationLog) in Mongo.
  2. Add a tag in Key Facts → CrmLead.tags gains the value.
  3. Add a task in "Tasks & reminders" → a CrmTask doc appears (not CrmReminder).
  4. Use the Activity tab "Schedule Call" → a CrmReminder doc with kind:"scheduled_call" appears and shows under dashboard "Upcoming Meetings".
  5. Type a meeting time in the Meeting Note tab → CrmLead.industryData.meeting_time is set, but nothing appears in "Upcoming Meetings".

Troubleshooting ​

SymptomCauseFix
Call counts wrong when querying CrmCommunicationLogCalls live in Contact, not the logQuery Contact by leadId/customerUID
A meeting typed in Meeting Note never shows in dashboard "Upcoming Meetings"Meeting Note writes CrmCommunicationLog + lead field, not CrmReminderUse Activity-tab "Schedule Call" (creates a CrmReminder)
CrmLead.lastContactedAt is null though the lead has activityField not written on every interaction (known gap)Use the derived last-contact (newest timeline row)
Engagement / Stage chips emptyCrmLeadJourney not yet generated by the AI engineExpected until a journey profile exists
"Tasks & reminders" panel missing a reminderPanel lists CrmTask only, not CrmReminderReminders show in the Activity tab + dashboard CRM Activity
  • LEAD-IMPORT-ATTRIBUTION.md — how CSV / Google Sheets imports map columns into these same lead fields, and the marketing-attribution columns.
  • Source investigations: .agents/tasks/crm-activity-investigation.md, .agents/tasks/lead-detail-panels-storage.md.

Changelog ​

  • Initial version — consolidated storage map for the lead detail page + dashboard CRM-Activity / Lead-stages widgets across nine collections.
  • Lead channel/source normalization — added the stored CrmLead.classification field (normalized channel/source/platform) and the Details & attribution Channel row; the Source row now reads classifyLead (persisted classification with a derived fallback). Mirrored in utils/leadClassification.utils.ts (backend) and lib/leadOrigin.ts (frontend). See Note B.

Last updated: