Salesforce ingestion for Call Context Service
This Dealops 2 server PR wires Salesforce call notes and opportunity context into the AI quoting Call Context Service instead of relying on hand-seeded rows.
A Salesforce-backed ingestion path for Dealops_Call_Note__c and Dealops_Opp_Context__c.
Rows land in CallRecord, OppContext, and CallSyncState, where the agent already reads evidence.
CallContextService.sync() is no longer a stub. It pulls one deal on demand through the same ingestion engine used by the scheduled sweep.
A 6-hour cron fans out one BullMQ job per enabled org. Enablement is data-driven: an org is in scope only when it has a CallContextOrgSyncState row.
Cursor semantics, provenance-preserving context merges, migration safety, and the Salesforce SOQL contract are the important edges.
1. Why this exists
sync() returned an honest error: no live CRM pull wired up yet.
- Real orgs had empty
CallRecordandOppContextrows. - The agent could not quote from calls unless someone manually seeded evidence.
evidence()stayed gated on missing or stale call sync state.
Salesforce becomes the first live CRM source for Call Context ingestion.
- Call notes and opp context are pulled from Dealops-owned Salesforce custom objects.
- Both scheduled backfill and on-demand deal refresh share the same dedupe and merge rules.
- Touched opportunities are marked fresh, making evidence quotable.
CallContextOrgSyncState row means the background job does not touch the org, which keeps orgs without the Dealops Salesforce objects out of this path.
2. What changes
Adds CallContextOrgSyncState as the org-level enablement gate and cursor store.
Splits call provenance into crmType and tool-level source.
Adds salesforceCallContextSource.ts, the only Salesforce-aware ingestion file.
It reads fixed Dealops object and field API names by SOQL.
Adds CRM-agnostic planners and writers in ingestCallContext.ts.
Dedupe uses organizationId + crmType + crmRecordId; unchanged calls skip writes via content hash.
Adds syncCallContext and syncCallContextForOrg.
The cron dispatches; per-org jobs own retry and failure isolation.
File map
Schema delta
| Entity | Change | Reason |
|---|---|---|
| CallContextOrgSyncState | New table keyed by organizationId; stores callNoteCursorAt, oppContextCursorAt, status, and lastError. |
One row both enables an org and remembers independent cursors per object. |
| CallRecord | Replaces old source CRM enum with crmType; adds nullable source as call-tool enum. |
Salesforce is the CRM, while Gong/Fireflies/Chorus/manual is the note source. Those are different dimensions. |
| CallRecord unique key | organizationId_source_crmRecordId → organizationId_crmType_crmRecordId |
Dedupe should not depend on call-tool label; the same CRM record should remain one row if Source changes. |
| OppContext | Adds includeInGoldenSet Boolean @default(false). |
Makes the “teaching example” flag queryable and preserves false as a real value. |
+2841 additions are mostly new ingestion code, tests, jobs, and migration; -37 deletions are primarily old stub/provenance assumptions.
3. How it works
Runtime flow
CallContextOrgSyncState row during onboarding.syncCallContext runs every 6 hours and queues one org job per enabled row.Dealops_Call_Note__c and Dealops_Opp_Context__c.CallSyncState; evidence() can return quotable context.Two doors, one engine
CallContextService.syncOrg(orgId)
- Used by
syncCallContextForOrg. - Fetch scope:
modifiedSince. - Advances org cursors only for successful halves.
- 6-hour cadence: bulk backfill and safety net, not freshness.
CallContextService.sync(deal)
- Fetch scope: one
Opportunity__cCRM id. - Ignores org cursors because reading one deal says nothing about the rest of the org.
- Skips CRM calls when
mode: ifStaleand the deal is already fresh. mode: forcealways pulls.
Key ingestion rules
| Rule | Implementation | Why it matters |
|---|---|---|
| Org opt-in | Background jobs read enabled orgs from CallContextOrgSyncState. |
No allowlist drift; orgs without Dealops Salesforce objects are never polled. |
| Independent cursors | callNoteCursorAt and oppContextCursorAt advance separately. |
A call-note failure cannot skip past opp-context changes, or vice versa. |
| Cursor overlap | cursorAfterPoll() rewinds poll start by 15 minutes. |
Records modified during a poll or under clock skew get re-read instead of skipped forever. |
| Call dedupe | Existing records load by crmType + crmRecordId; content hash decides unchanged vs update. |
Re-reading overlap windows is cheap and safe. |
| Context provenance | customer and extracted outrank synced. |
A CRM sync never overwrites a human-entered or extracted value. |
| Partial records | Missing required call-note identity/body fields are warnings and skips, not poll failures. | One bad CRM row does not block the org. |
Salesforce contract
The API names are centralized in dealopsObjects.ts because they are not per-org mapped fields.
Dealops_Call_Note__c
Dealops_Opp_Context__c
Opportunity__c
LastModifiedDate
The PR intentionally does not use crmObjectService.queryObjects(), because that path only emits equality filters and this needs LastModifiedDate > cursor.
Service API changes
CallRecord.source:
'salesforce' | 'hubspot' | 'manual'
sync():
'error' unless already fresh
CallRecord.crmType:
'salesforce' | 'hubspot'
CallRecord.source:
'gong' | 'fireflies' | 'chorus' | 'manual' | 'other' | null
sync():
pulls one deal via CRM source
syncOrg():
sweeps enabled org via cursors
4. Tests and coverage
ingestCallContext.test.ts adds database-backed coverage around the real ingestion engine.
Only the CRM network boundary is faked.
Each case creates its own org, user, and OpportunityV2, then tears them down.
No customer org is required.
Assertions read through the real CallContextService, including evidence().
| Covered behavior | Why reviewers should care |
|---|---|
| Happy path calls + context land end-to-end | Confirms the agent reads what ingestion wrote. |
| Unchanged records are no-ops; edits update in place | Validates content hash and CRM-record dedupe. |
| Customer provenance survives sync | Prevents CRM backfill from clobbering higher-trust values. |
| Empty CRM fields do not clear stored values | Matches partial-object semantics. |
| Unknown opportunities are skipped and counted | Normal CRM noise does not fail a poll. |
| Failed poll marks org broken with cursors intact | Prevents silent data loss across failures. |
On-demand sync() and org syncOrg() share dedupe |
Makes two entry points safe to run against the same CRM record. |
| Large batch of 50 call notes | Exercises bulk ingest behavior without pinning query counts. |
5. What it doesn't change
- Does not add a new tRPC route; this changes server service behavior and background jobs.
- Does not make the 6-hour job a freshness mechanism for a rep actively opening a deal; on-demand
sync()is the freshness door. - Does not onboard every org automatically; orgs need an explicit
CallContextOrgSyncStaterow. - Does not make call body cleanup or summarization live;
bodyTyperemains served asraw. - Does not use per-org
crmObjectSpecsmappings for these Salesforce objects; the object contract is fixed. - Does not add HubSpot ingestion, though the source interface leaves room for it.
- Does not hard-delete call context data; the existing soft-delete posture remains.
6. Risks / rollback / open questions
The migration drops the old CallRecord.source enum column and re-adds source as call-tool provenance.
Review whether existing rows need data backfill beyond defaulting crmType to salesforce.
The source type says implementations must return oldest-modified first, while the SOQL implementation orders by Id.
Verify this is harmless with the poll-start cursor model, or align the query/comment before merge.
Partial object failures mark the org broken, preserve failed cursors, and still keep the successful half.
The per-org BullMQ job then throws so retries and queue failure visibility still work.
Soft-delete or remove an org's CallContextOrgSyncState row to stop background ingestion for that org.
Unschedule/disable syncCallContext to stop all fan-out while leaving on-demand service code in place.
- Run and verify the Prisma migration against the target environment.
- Insert the first
CallContextOrgSyncStaterow deliberately, likely for Merge as called out in the PR. - Confirm the org has both Salesforce custom objects and the exact fixed field API names.
- Watch
lastError, job failures, and warning telemetry for missing objects, bad JSON, unknown opportunities, and unrecognized call-tool labels.