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 element | Rendered by | Collection + model file | Field(s) | Kind |
|---|---|---|---|---|
| Header Engagement chip | LeadProfileOverview.tsx | CrmLeadJourney (models/CrmLeadJourney.ts), via lead.journey | journey.engagementScore | External (AI) |
Header Interactions (N, N calls · Xs) | LeadProfileOverview.tsx | merged timeline (Contact + CrmCommunicationLog) | communications.length, stats.totalCalls, summed duration | Derived |
Header Success Rate (X%, X/Y calls) | LeadProfileOverview.tsx | Contact (call records) | from call outcome (completed = connected) | Derived |
| Header Stage chip | LeadProfileOverview.tsx | CrmLeadJourney via lead.journey | journey.buyingStage | External (AI) |
| Status pill + temperature pill (under name) | LeadProfileOverview.tsx | CrmLead (models/CrmLead.ts) | status, leadTemperature | Stored |
| Activity by channel (Calls/WhatsApp/SMS/Email/Notes counts) | LeadProfileOverview.tsx | Calls → Contact; rest → CrmCommunicationLog | count of timeline rows by type (+ isWhatsAppEntry) | Derived |
| Key Facts → Status | LeadDetailPage.tsx | CrmLead | status | Stored |
| Key Facts → Tags | LeadDetailPage.tsx | CrmLead | tags: string[] | Stored |
| Details & attribution → Status / Email / Created | LeadProfileOverview.tsx | CrmLead | status, email, createdAt | Stored |
| Details & attribution → Assigned to | LeadProfileOverview.tsx | CrmLead | assignedUserUIDs[] (fallback assignedToUID); name resolved from org users | Stored (id) / Derived (name) |
| Details & attribution → Channel | LeadProfileOverview.tsx (classifyLead) | CrmLead | classification.channel (persisted) ?? derived from metadata.source + sourceHistory[]; platform sub-detail from metadata.platform/metaPlatform | Stored + Derived (see Note B) |
| Details & attribution → Source | LeadProfileOverview.tsx (classifyLead) | CrmLead | classification.source (persisted) ?? derived (Meta platform → utm → referrer=Organic → Direct) | Stored + Derived (see Note B) |
| Details & attribution → Last contact | LeadDetailPage.tsx (effectiveLastContactedAt) | merged timeline, fallback CrmLead.lastContactedAt | newest timeline timestamp | Derived (see Note C) |
| Marketing Attribution panel (First touch / Latest touch / Source / Tracking IDs) | LeadAttributionCard.tsx + lib/leadOrigin.ts | CrmLead | attribution.{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.tsx | defs → CrmCustomField; values → CrmLead | defs: fieldKey/displayName/origin; values: CrmLead.industryData[fieldKey] | Stored (two collections; Note D) |
| Conversation Timeline + tabs (All/WhatsApp/Call/Activity/Meeting Note) | LeadDetailPage.tsx | Calls → Contact; rest → CrmCommunicationLog | merged rows; filter on type + isWhatsAppEntry + metadata.kind | Stored rows / Derived merge (Note A) |
| Website Activity panel | LeadActivityPanel.tsx → GET /api/leads/:leadId/activity | CrmWebSession + CrmWebEvent | session sessionId/source/startedAt; event eventName/eventTime/page; keyed by leadId | Stored (implemented; Note E) |
| Opportunities panel (deals) | LeadOpportunitiesPanel.tsx | CrmOpportunity (models/CrmOpportunity.ts) | opportunityId, contactId (= leadId), name, amount, stage, expectedCloseDate, isWon/isClosed | Stored |
| Apollo enrichment panel | LeadApolloEnrichmentCard.tsx | CrmLead | industryData.{company,title,seniority,department,industry,linkedin_url,employee_count}; metadata.source="apollo"/metadata.apolloId | Stored (lands on the lead; Note F) |
| Tasks & reminders panel | LeadTasksPanel.tsx | CrmTask (models/CrmTask.ts) — separate from CrmReminder | taskId, leadId, title, description, status (open/done), priority (low/medium/high), dueDate, assignedToUID | Stored (Note G) |
| Activity tab → Set Reminder / Schedule Call | SetReminderModal.tsx | CrmReminder (models/CrmReminder.ts) | reminderId, leadId, remindAt, note, kind (reminder/scheduled_call), status | Stored (Note G) |
| Meeting Note tab → meeting time | LeadDetailPage.tsx | CrmCommunicationLog (type=comment, metadata.kind="meeting_note") + CrmLead common fields | log metadata.meetingTime/meetingLocation/callBackTime; lead industryData.meeting_time/meeting_location/call_back_time | Stored (Note H) |
| Dashboard CRM Activity → Upcoming Meetings | DashboardPage.tsx | CrmReminder via GET /api/reminders?status=pending | pending reminders, kind === "scheduled_call", future remindAt | Derived split (Note G) |
| Dashboard CRM Activity → Follow-ups | DashboardPage.tsx | CrmReminder | pending reminders, kind !== "scheduled_call", future remindAt | Derived split (Note G) |
| Dashboard Overview → Leads (Total/Contacted/Converted/Lost) | DashboardPage.tsx | CrmLead via GET /api/leads/stats | counts over grouped CrmLead.status | Derived rollup (Note I) |
| Dashboard Overview → Campaigns | DashboardPage.tsx | Campaign collection via campaignService.list() | isRunning + stats.{in_progress,pending,total} | Derived |
| Dashboard Lead stages bar list | DashboardPage.tsx → dashboardLeadStages.ts | CrmLead via GET /api/leads/stats | byStatus (grouped status); names from statusOptions | Derived (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); elseutm_source; else an external referrer →Organic; elseDirect. 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 byLeadAttributionCard.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)
CrmTask | CrmReminder | |
|---|---|---|
| Model file | models/CrmTask.ts | models/CrmReminder.ts |
| Shown in | "Tasks & reminders" panel + the Tasks nav/page | Lead Activity tab + dashboard CRM Activity (Upcoming Meetings / Follow-ups) |
| Shape | assignable 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
| Collection | Model file | Role on this page |
|---|---|---|
CrmLead | models/CrmLead.ts | The lead record: status, temperature, tags, email, assignment, opportunityValue, industryData/columns (custom-field + Apollo values), metadata, attribution, sourceHistory, classification (normalized channel/source/platform), createdAt, lastContactedAt |
Contact | telephony call model | Calls — timeline rows + call stats (single source of truth) |
CrmCommunicationLog | models/CrmCommunicationLog.ts | Non-call timeline: comments/meeting notes, email, SMS/WhatsApp, status changes, transactions |
CrmLeadJourney | models/CrmLeadJourney.ts | AI read-model → Engagement + Stage chips (external; null until the journey engine runs) |
CrmOpportunity | models/CrmOpportunity.ts | Deals (Opportunities panel), joined by contactId = leadId |
CrmTask | models/CrmTask.ts | Tasks panel + Tasks nav |
CrmReminder | models/CrmReminder.ts | Activity-tab reminders/scheduled calls + dashboard CRM Activity |
CrmWebSession + CrmWebEvent | models/CrmWebSession.ts, models/CrmWebEvent.ts | Website Activity panel |
CrmCustomField | models/CrmCustomField.ts | Custom-field DEFINITIONS (incl. COMMON_FIELDS); values live on CrmLead |
How to Test (verify the mapping)
- Open a lead with calls and notes → the Conversation Timeline shows both; confirm calls exist in
Contact(notCrmCommunicationLog) in Mongo. - Add a tag in Key Facts →
CrmLead.tagsgains the value. - Add a task in "Tasks & reminders" → a
CrmTaskdoc appears (notCrmReminder). - Use the Activity tab "Schedule Call" → a
CrmReminderdoc withkind:"scheduled_call"appears and shows under dashboard "Upcoming Meetings". - Type a meeting time in the Meeting Note tab →
CrmLead.industryData.meeting_timeis set, but nothing appears in "Upcoming Meetings".
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Call counts wrong when querying CrmCommunicationLog | Calls live in Contact, not the log | Query Contact by leadId/customerUID |
| A meeting typed in Meeting Note never shows in dashboard "Upcoming Meetings" | Meeting Note writes CrmCommunicationLog + lead field, not CrmReminder | Use Activity-tab "Schedule Call" (creates a CrmReminder) |
CrmLead.lastContactedAt is null though the lead has activity | Field not written on every interaction (known gap) | Use the derived last-contact (newest timeline row) |
| Engagement / Stage chips empty | CrmLeadJourney not yet generated by the AI engine | Expected until a journey profile exists |
| "Tasks & reminders" panel missing a reminder | Panel lists CrmTask only, not CrmReminder | Reminders show in the Activity tab + dashboard CRM Activity |
Related docs
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.classificationfield (normalizedchannel/source/platform) and the Details & attribution Channel row; the Source row now readsclassifyLead(persistedclassificationwith a derived fallback). Mirrored inutils/leadClassification.utils.ts(backend) andlib/leadOrigin.ts(frontend). See Note B.