mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
fix(call-attribution): resolve Ozonetel chain AgentNames to agent.id
Inbound transferred calls arrive with AgentName like 'RamaiahAdmin -> GlobalHealthX'. The webhook was persisting the raw chain string and leaving agentId null; the CDR enrichment cron then silently skipped 100% of rows because the bulk CDR keys on caller-leg UCID while the webhook stores monitorUCID — the join never matched. - missed-call-webhook: split chain on ' -> ', take final handler, resolve via AgentLookupService (ozonetelAgentId + display name) - cdr-enrichment: index CDR rows by both UCID and monitorUCID so the cron actually patches historical rows - enrichment also parses chain in CDR AgentName as a second fallback - spec: add CallerResolutionService + AgentLookupService mocks
This commit is contained in:
@@ -63,10 +63,18 @@ export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (cdrRows.length === 0) continue;
|
if (cdrRows.length === 0) continue;
|
||||||
|
|
||||||
// Build UCID → cdr-row map so we can O(1) join per Call.
|
// Build UCID → cdr-row map so we can O(1) join per Call.
|
||||||
|
// Ozonetel emits two identifiers per call — `UCID` (caller-leg)
|
||||||
|
// and `monitorUCID` (agent-leg). The webhook stores `monitorUCID`,
|
||||||
|
// but the bulk CDR rows are keyed on caller-leg `UCID`. Index
|
||||||
|
// both so the lookup at line ~79 finds the row regardless of
|
||||||
|
// which side was persisted. Without this, transferred inbound
|
||||||
|
// calls never get their agent relation enriched.
|
||||||
const byUcid = new Map<string, any>();
|
const byUcid = new Map<string, any>();
|
||||||
for (const row of cdrRows) {
|
for (const row of cdrRows) {
|
||||||
const ucid = String(row.UCID ?? '').trim();
|
const ucid = String(row.UCID ?? '').trim();
|
||||||
|
const monitorUcid = String(row.monitorUCID ?? '').trim();
|
||||||
if (ucid) byUcid.set(ucid, row);
|
if (ucid) byUcid.set(ucid, row);
|
||||||
|
if (monitorUcid && monitorUcid !== ucid) byUcid.set(monitorUcid, row);
|
||||||
}
|
}
|
||||||
if (byUcid.size === 0) continue;
|
if (byUcid.size === 0) continue;
|
||||||
|
|
||||||
@@ -80,9 +88,25 @@ export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!cdrRow) { skipped++; continue; }
|
if (!cdrRow) { skipped++; continue; }
|
||||||
|
|
||||||
const patch: Record<string, any> = {};
|
const patch: Record<string, any> = {};
|
||||||
const cdrAgentId = cdrRow.AgentID;
|
if (!call.agentId) {
|
||||||
if (cdrAgentId && !call.agentId) {
|
// Primary resolution: use AgentID from CDR (unique lowercase id).
|
||||||
const uuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId);
|
const cdrAgentId = cdrRow.AgentID;
|
||||||
|
let uuid = cdrAgentId
|
||||||
|
? await this.agentLookup.resolveByOzonetelId(cdrAgentId)
|
||||||
|
: null;
|
||||||
|
// Fallback: CDR AgentName may be a chain ("A -> B") for
|
||||||
|
// transferred calls. Pick the final handler (last segment)
|
||||||
|
// and look it up by display name or ozonetelId. Matches
|
||||||
|
// the write-time resolution in missed-call-webhook.
|
||||||
|
if (!uuid && cdrRow.AgentName) {
|
||||||
|
const segments = String(cdrRow.AgentName).split('->').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const finalHandler = segments[segments.length - 1];
|
||||||
|
if (finalHandler) {
|
||||||
|
uuid =
|
||||||
|
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||||
|
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (uuid) patch.agentId = uuid;
|
if (uuid) patch.agentId = uuid;
|
||||||
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export class MissedCallWebhookController {
|
|||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly caller: CallerResolutionService,
|
private readonly caller: CallerResolutionService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -197,6 +199,25 @@ export class MissedCallWebhookController {
|
|||||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve agent relation at write-time so the supervisor dashboard
|
||||||
|
// can bucket the row immediately. Ozonetel sends transferred calls
|
||||||
|
// with a chain-style AgentName like "RamaiahAdmin -> GlobalHealthX" —
|
||||||
|
// the final handler is the last segment, so split on " -> " and
|
||||||
|
// resolve that. Try both ozonetelAgentId (lowercase unique) and
|
||||||
|
// ozonetelDisplayName (mixed-case human label) since Ozonetel mixes
|
||||||
|
// formats across webhook payloads. Leaves agentId null on miss so
|
||||||
|
// the cdr-enrichment cron can still attempt a match by UCID later.
|
||||||
|
if (data.agentName) {
|
||||||
|
const segments = data.agentName.split('->').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const finalHandler = segments[segments.length - 1];
|
||||||
|
if (finalHandler) {
|
||||||
|
const uuid =
|
||||||
|
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||||
|
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||||
|
if (uuid) callData.agentId = uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.platform.queryWithAuth<any>(
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||||
{ data: callData },
|
{ data: callData },
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Test } from '@nestjs/testing';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import {
|
import {
|
||||||
WEBHOOK_INBOUND_ANSWERED,
|
WEBHOOK_INBOUND_ANSWERED,
|
||||||
WEBHOOK_INBOUND_MISSED,
|
WEBHOOK_INBOUND_MISSED,
|
||||||
@@ -48,11 +50,28 @@ describe('MissedCallWebhookController', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockCaller = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
leadId: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
patientId: '',
|
||||||
|
isNew: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAgentLookup = {
|
||||||
|
resolveByOzonetelId: jest.fn().mockResolvedValue(null),
|
||||||
|
resolveByDisplayName: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
controllers: [MissedCallWebhookController],
|
controllers: [MissedCallWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
||||||
{ provide: ConfigService, useValue: mockConfig },
|
{ provide: ConfigService, useValue: mockConfig },
|
||||||
|
{ provide: CallerResolutionService, useValue: mockCaller },
|
||||||
|
{ provide: AgentLookupService, useValue: mockAgentLookup },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user