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.

Author: @pk675 PR: #6633 Area: dealops2/aiQuoting Stacked on: #6632 / DEA-7274 Ticket: DEA-7275 Files: 9 Diff: +983 / -4 Status: open

What it adds

A new CallContextService implementation with five functions: evidence, listCalls, getCall, getContext, and sync.

What it reads

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.

What it tests

Eight new Merge Test Org e2e tests seed real rows, call the real service through chain(), and assert behavior without service mocks.

What it defers

sync() does not fake CRM ingestion. Fresh deals can be skipped; all other sync attempts return an explicit error until the live puller lands.

CallContextService
CallRecord
OppContext
CallSyncState
Chain builders

1. Why this exists

Problem

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.
Fix

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.
Design stance: this PR chooses a boring service layer first. It reads persisted call context accurately and leaves the live Salesforce/HubSpot puller as a clearly marked later integration point.

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

Before
  • 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.
After
  • buildCallContextService() returns the real Prisma-backed service.
  • Shared e2e helpers live in e2e_tests/framework/callContextChainSteps.ts.
  • Both chain builders expose the same seedCallContext() and expect* steps.

Read path diagram

1
Test creates deal
chain().newBusiness() creates a real OpportunityV2.
2
Fixture rows seeded
Chain builders call shared seeding.
3
Service reads Prisma
CallContextService queries real tables.
4
Rows mapped to types
Dates become ISO strings; raw bodies stay raw.
5
Partial assertions
Tests use partialMatch through expect* steps.

Meaningful files

apps/server/src/dealops2/aiQuoting/callContext/callContextService.ts
New Prisma-backed implementation of the service contract.
+216
apps/server/src/dealops2/aiQuoting/callContext/testSeeding.ts
Test-only writer for call records, opportunity context, and sync state.
+69
apps/server/e2e_tests/framework/callContextChainSteps.ts
Shared seed/assert implementation used by both chain builders.
+176
apps/server/e2e_tests/seed/chainBuilder.ts and apps/server/e2e_tests/engagement/chainBuilder.ts
Add thin chain methods and dispatchers for call context steps.
+319
apps/server/e2e_tests/orgs/merge-test/tests.ts
Adds eight e2e tests for DEA-7275 acceptance behavior.
+151

3. How it works

Soft-delete discipline

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,
}
Raw body contract

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.

Org isolation

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

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.

Context

getContext() maps the Prisma row into the public OppContext shape.

  • attributes defaults to {}
  • fieldSources defaults to {}
  • competitors passes through as an array

Chain builders integration

Shared e2e steps, two harnesses

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

Already fresh

When mode === 'ifStale' and sync state is fresh, the service returns:

{
  outcome: 'skipped_fresh',
  callsAdded: 0,
  callsUpdated: 0,
  contextUpdated: false
}
Everything else today

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

8
new Merge tests
0
service mocks
5
service functions covered
2
chain builders wired
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.
Local test note: the PR author did not run the Merge e2e suite locally because local Postgres was behind migrations and 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

Explicit non-goals

6. Risks / rollback / open questions

Review focus
Rollback shape: remove the service file, test seeding helper, shared chain-step module, chain builder step wiring, and the eight Merge tests. No production data migration is introduced by this PR.