Call Context Service for AI Quoting
This PR adds the Dealops 2 service layer for call context reads, fixture seeding, and real Merge Test Org e2e coverage over the schema from #6632.
A new CallContextService implementation with five functions: evidence, listCalls, getCall, getContext, and sync.
Plain Prisma reads over the call context tables introduced in the stacked schema PR. Every query filters deletedAt: null, matching the soft-delete-only model.
Eight new Merge Test Org e2e tests seed real rows, call the real service through chain(), and assert behavior without service mocks.
sync() does not fake CRM ingestion. Fresh deals can be skipped; all other sync attempts return an explicit error until the live puller lands.
1. Why this exists
DEA-7274 / #6632 adds the call context schema, but schema alone does not give AI Quoting a stable read boundary.
- Call notes need a single service contract.
- Opportunity context needs provenance-aware reads.
- Sync state must be honest while live CRM pulling is not wired.
DEA-7275 adds a narrow service implementation and e2e harness steps that prove the service against real database rows.
- Dealops 2 server-side code only.
- No new tRPC route in this PR.
- No production writer or CRM ingestion path yet.
2. What changes
Service surface
| Function | Source tables | Behavior added | Important constraint |
|---|---|---|---|
evidence(deal) |
CallRecord, OppContext, CallSyncState | Returns call count, newest call timestamp, context presence, sync state, and ok vs no_evidence. |
No calls returns a user-facing reason string. |
listCalls(deal, opts) |
CallRecord | Returns newest-first call records with default limit 10. |
type: 'clean' is accepted but still returns bodyType: 'raw'. |
getCall(ref) |
CallRecord | Fetches one call by id. | Scopes by organizationId to prevent cross-org leakage. |
getContext(deal) |
OppContext | Returns deal context fields, attributes, competitors, and field provenance. | Returns null when no context row exists. |
sync(deal, opts) |
CallSyncState | Skips already-fresh records in ifStale mode. |
Otherwise returns error: 'no live CRM pull wired up yet'. |
Before / after structure
- Schema exists in the stacked PR, but no service implementation reads it.
- Merge Test Org uses the engagement-contract e2e harness, not the flat seed harness.
- Call context test logic would have been easy to duplicate across builders.
buildCallContextService()returns the real Prisma-backed service.- Shared e2e helpers live in
e2e_tests/framework/callContextChainSteps.ts. - Both chain builders expose the same
seedCallContext()andexpect*steps.
Read path diagram
chain().newBusiness() creates a real OpportunityV2.partialMatch through expect* steps.Meaningful files
apps/server/src/dealops2/aiQuoting/callContext/callContextService.ts
apps/server/src/dealops2/aiQuoting/callContext/testSeeding.ts
apps/server/e2e_tests/framework/callContextChainSteps.ts
apps/server/e2e_tests/seed/chainBuilder.ts and apps/server/e2e_tests/engagement/chainBuilder.ts
apps/server/e2e_tests/orgs/merge-test/tests.ts
3. How it works
Every service read includes deletedAt: null. This is intentional because call context data is soft-delete only.
where: {
organizationId: deal.orgId,
opportunityV2Id: deal.opportunityV2Id,
deletedAt: null,
}
Call bodies always return as raw in v1, even when callers request clean text.
bodyType: 'raw'
The test suite locks this in so type: 'clean' is not a silent untested branch.
getCall() looks up by both call id and org id. A seeded call requested from another org returns null.
where: {
id: ref.callId,
organizationId: ref.orgId,
deletedAt: null,
}
CallContextService function behavior
evidence() performs four reads in parallel:
- non-deleted call count
- newest call date
- whether context exists
- current sync state, defaulting to
never
If call count is zero, it returns verdict: 'no_evidence' plus the retry guidance string.
getContext() maps the Prisma row into the public OppContext shape.
attributesdefaults to{}fieldSourcesdefaults to{}competitorspasses through as an array
Chain builders integration
Merge Test Org uses EngagementChainBuilder over Account → Engagement → Contract. The original flat seed/ChainBuilder still exists for other harnesses.
| Builder | New public methods | Implementation pattern |
|---|---|---|
e2e_tests/engagement/chainBuilder.ts |
seedCallContext, expectEvidence, expectCalls, expectCall, expectContext, expectSync |
Thin dispatcher into runSeedCallContext() and runExpect*. |
e2e_tests/seed/chainBuilder.ts |
Same six methods. | Same shared helper module; no duplicated seed/assert logic. |
const { callIds } = await runSeedCallContext(
state.config.organizationId,
state.currentOpportunityId!,
input,
);
state.lastSeededCallIds = callIds;
Sync is intentionally limited
When mode === 'ifStale' and sync state is fresh, the service returns:
{
outcome: 'skipped_fresh',
callsAdded: 0,
callsUpdated: 0,
contextUpdated: false
}
Until the live CRM puller exists, the service returns a truthful error instead of pretending ingestion happened.
{
outcome: 'error',
callsAdded: 0,
callsUpdated: 0,
contextUpdated: false,
error: 'no live CRM pull wired up yet'
}
4. E2E coverage added
| Test | Behavior locked | Why reviewers should care |
|---|---|---|
| evidence ok | Calls exist → verdict: 'ok', correct count. |
Confirms basic positive evidence path. |
| evidence empty | No calls → no_evidence with reason. |
Confirms caller-visible remediation messaging. |
| listCalls limit/order | Honors limit and returns newest first. |
Prevents prompt context from being unordered or oversized. |
| listCalls clean request | type: 'clean' still returns bodyType: 'raw'. |
Makes the v1 limitation explicit and tested. |
| getCall org isolation | Wrong org id returns null. |
Protects against cross-tenant leakage. |
| getContext full fields | All seeded context fields and provenance return. | Validates the shape AI Quoting will consume. |
| getContext empty | No row returns null. |
Confirms missing context is not treated as an empty object. |
| sync fresh / sync no puller | Fresh skips; non-fresh errors honestly. | Prevents false-positive ingestion state. |
productionGuard.ts correctly refused the configured Neon database. CI is expected to run these against fresh Postgres for Merge Test Org.
5. What it doesn't change
- No live Salesforce, HubSpot, Gong, or call-tool puller is added.
- No new tRPC endpoint or client UI is introduced.
- No production write API for call context is created;
testSeeding.tsis test-only fixture plumbing. - No clean-call-body pipeline exists; v1 always serves
bodyType: 'raw'. - No schema migration is included here; this PR depends on #6632 for the call context tables.
- No pricing, quote update, close, or writeback behavior changes in the e2e flows.
6. Risks / rollback / open questions
- Stack dependency: this service assumes the #6632 schema is present. Roll back this PR before or with the schema PR if the stack is reverted.
- CI dependency: the new Merge tests were not run locally; the first full execution is the Merge engagement-model e2e job.
- Sync semantics:
forcecurrently returns the same explicit no-puller error as stale/never states. That is correct for now, but the future CRM puller must replace this branch. - Fixture direct writes:
seedCallContextFixture()writes straight to Prisma tables by design for e2e setup. Keep it test-only.