mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
5 Commits
v0.13-ai-c
...
3bb4315925
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb4315925 | |||
| 7402590969 | |||
| 3f22166ac0 | |||
| 8c8b1e78b0 | |||
| 77b3e917db |
@@ -773,8 +773,8 @@ export class AiChatController {
|
|||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
||||||
if (packages.length) {
|
|
||||||
sections.push('\n## Health Packages');
|
sections.push('\n## Health Packages');
|
||||||
|
if (packages.length) {
|
||||||
for (const p of packages) {
|
for (const p of packages) {
|
||||||
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
||||||
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
||||||
@@ -791,6 +791,8 @@ export class AiChatController {
|
|||||||
sections.push(` Includes: ${p.inclusions}`);
|
sections.push(` Includes: ${p.inclusions}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
sections.push('No packages available.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export class CallerContextService {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async invalidateCache(leadId: string): Promise<void> {
|
||||||
|
if (!leadId) return;
|
||||||
|
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
|
||||||
|
await this.session.deleteCache(cacheKey).catch(() => {});
|
||||||
|
this.logger.log(`[CALLER-CTX] Cache invalidated for ${leadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Fire-and-forget pre-warm — called from caller resolution
|
// Fire-and-forget pre-warm — called from caller resolution
|
||||||
// so the cache is hot when the AI stream fires seconds later.
|
// so the cache is hot when the AI stream fires seconds later.
|
||||||
prewarm(leadId: string, patientId: string, auth: string): void {
|
prewarm(leadId: string, patientId: string, auth: string): void {
|
||||||
@@ -89,8 +96,8 @@ export class CallerContextService {
|
|||||||
|
|
||||||
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
||||||
try {
|
try {
|
||||||
const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([
|
// Step 1: Fetch lead first to get the authoritative patientId
|
||||||
this.platform.queryWithAuth<any>(
|
const leadData = await this.platform.queryWithAuth<any>(
|
||||||
`{ lead(filter: { id: { eq: "${leadId}" } }) {
|
`{ lead(filter: { id: { eq: "${leadId}" } }) {
|
||||||
id contactName { firstName lastName }
|
id contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
@@ -99,9 +106,24 @@ export class CallerContextService {
|
|||||||
utmCampaign patientId
|
utmCampaign patientId
|
||||||
} }`,
|
} }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
),
|
);
|
||||||
patientId ? this.platform.queryWithAuth<any>(
|
|
||||||
`{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
const lead = leadData?.lead;
|
||||||
|
if (!lead) return null;
|
||||||
|
|
||||||
|
// Use Lead's patientId as authoritative source — the input
|
||||||
|
// param may be empty if caller resolution just linked them.
|
||||||
|
const resolvedPatientId = patientId || lead.patientId || '';
|
||||||
|
this.logger.log(`[CALLER-CTX] Resolved patientId=${resolvedPatientId} (input=${patientId}, lead=${lead.patientId ?? '∅'})`);
|
||||||
|
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
|
||||||
|
// Step 2: Fetch appointments, calls, activities in parallel
|
||||||
|
// using the resolved patientId from the Lead record.
|
||||||
|
const [appointmentsData, callsData, activitiesData] = await Promise.all([
|
||||||
|
resolvedPatientId ? this.platform.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 10, filter: { patientId: { eq: "${resolvedPatientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
scheduledAt status doctorName department reasonForVisit
|
scheduledAt status doctorName department reasonForVisit
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
@@ -120,12 +142,6 @@ export class CallerContextService {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lead = leadData?.lead;
|
|
||||||
if (!lead) return null;
|
|
||||||
|
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
|
||||||
|
|
||||||
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
|
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
|
||||||
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
|
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
|
||||||
startedAt: e.node.startedAt,
|
startedAt: e.node.startedAt,
|
||||||
@@ -148,7 +164,7 @@ export class CallerContextService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
leadId,
|
leadId,
|
||||||
patientId: patientId || lead.patientId || '',
|
patientId: resolvedPatientId,
|
||||||
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
||||||
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
|
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
|
||||||
isNew: false,
|
isNew: false,
|
||||||
|
|||||||
@@ -33,4 +33,13 @@ export class CallerResolutionController {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('invalidate-context')
|
||||||
|
async invalidateContext(@Body('leadId') leadId: string) {
|
||||||
|
if (!leadId) {
|
||||||
|
throw new HttpException('leadId is required', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
await this.callerContext.invalidateCache(leadId);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ RULES:
|
|||||||
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
|
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
|
||||||
6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||||
7. NEVER give medical advice, diagnosis, or treatment recommendations.
|
7. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||||
8. Format with bullet points for easy scanning.
|
|
||||||
|
|
||||||
RESPONSE FORMAT (STRICT):
|
RESPONSE FORMAT (STRICT):
|
||||||
You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON:
|
You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON:
|
||||||
|
|||||||
@@ -156,11 +156,13 @@ export class OzonetelAgentController {
|
|||||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create call record for outbound calls. Inbound calls are
|
// Create call record at dispose time for ALL answered calls
|
||||||
// created by the webhook — but we skip outbound in the webhook
|
// (inbound + outbound). The dispose endpoint fires BEFORE the
|
||||||
// (they're not "missed calls"). So the dispose endpoint is the
|
// CDR webhook, so creating here gives us the correct agent-side
|
||||||
// only place that creates the call record for outbound dials.
|
// UCID and the agent's chosen disposition immediately. The webhook
|
||||||
if (body.direction === 'OUTBOUND' && body.callerPhone) {
|
// arrives ~5s later and enriches with recording URL + chain name.
|
||||||
|
if (body.callerPhone) {
|
||||||
|
const isInbound = body.direction !== 'OUTBOUND';
|
||||||
try {
|
try {
|
||||||
const durationSec = body.durationSec ?? 0;
|
const durationSec = body.durationSec ?? 0;
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
@@ -168,8 +170,8 @@ export class OzonetelAgentController {
|
|||||||
? new Date(Date.now() - durationSec * 1000).toISOString()
|
? new Date(Date.now() - durationSec * 1000).toISOString()
|
||||||
: endedAt;
|
: endedAt;
|
||||||
const callData: Record<string, any> = {
|
const callData: Record<string, any> = {
|
||||||
name: `Outbound — ${body.callerPhone}`,
|
name: isInbound ? `Inbound — ${body.callerPhone}` : `Outbound — ${body.callerPhone}`,
|
||||||
direction: 'OUTBOUND',
|
direction: isInbound ? 'INBOUND' : 'OUTBOUND',
|
||||||
callStatus: 'COMPLETED',
|
callStatus: 'COMPLETED',
|
||||||
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
||||||
agentName: agentId,
|
agentName: agentId,
|
||||||
@@ -196,7 +198,7 @@ export class OzonetelAgentController {
|
|||||||
{ data: callData },
|
{ data: callData },
|
||||||
`Bearer ${apiKey}`,
|
`Bearer ${apiKey}`,
|
||||||
);
|
);
|
||||||
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
this.logger.log(`[DISPOSE] Created ${isInbound ? 'inbound' : 'outbound'} call record: ${result.createCall.id} ucid=${body.ucid} disposition=${body.disposition} phone=${body.callerPhone}`);
|
||||||
|
|
||||||
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
||||||
const callId = result.createCall.id;
|
const callId = result.createCall.id;
|
||||||
@@ -278,33 +280,9 @@ export class OzonetelAgentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update disposition on answered inbound calls. The webhook creates
|
// Inbound disposition is now handled by the call record creation
|
||||||
// the Call record with the Ozonetel default disposition ("General
|
// above — the dispose endpoint creates the record with the correct
|
||||||
// Enquiry" → INFO_PROVIDED) before the agent disposes. Now that the
|
// disposition. No separate update-by-UCID needed.
|
||||||
// agent has submitted their actual disposition, write it back to the
|
|
||||||
// platform Call record by matching on UCID.
|
|
||||||
//
|
|
||||||
// Skipped for outbound (already created with correct disposition
|
|
||||||
// above) and for missed-call callbacks (handled in the block above).
|
|
||||||
if (!body.missedCallId && body.direction !== 'OUTBOUND' && body.ucid) {
|
|
||||||
try {
|
|
||||||
const callData = await this.platform.query<any>(
|
|
||||||
`{ calls(first: 1, filter: { ucid: { eq: "${body.ucid}" } }) { edges { node { id } } } }`,
|
|
||||||
);
|
|
||||||
const callId = callData?.calls?.edges?.[0]?.node?.id;
|
|
||||||
if (callId) {
|
|
||||||
await this.platform.query<any>(
|
|
||||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
|
||||||
{ id: callId, data: { disposition: body.disposition } },
|
|
||||||
);
|
|
||||||
this.logger.log(`[DISPOSE] Updated inbound call ${callId} disposition → ${body.disposition}`);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`[DISPOSE] No Call found for ucid=${body.ucid} — disposition not persisted`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.warn(`[DISPOSE] Failed to update inbound call disposition: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-assign next missed call to this agent
|
// Auto-assign next missed call to this agent
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
// monitorUCID → agentUCID. Real-time events carry both; CDR webhook only has monitorUCID.
|
||||||
|
private readonly ucidMap = new Map<string, string>();
|
||||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
||||||
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
|
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
|
||||||
// Worklist update stream — emitted when a missed call is created or
|
// Worklist update stream — emitted when a missed call is created or
|
||||||
@@ -78,9 +80,14 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveAgentUcid(monitorUcid: string): string | null {
|
||||||
|
return this.ucidMap.get(monitorUcid) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
handleCallEvent(event: any) {
|
handleCallEvent(event: any) {
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
const ucid = event.ucid ?? event.monitorUCID;
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
|
const monitorUcid = event.monitor_ucid ?? event.monitorUCID;
|
||||||
const agentId = event.agent_id ?? event.agentID;
|
const agentId = event.agent_id ?? event.agentID;
|
||||||
const callerNumber = event.caller_id ?? event.callerID;
|
const callerNumber = event.caller_id ?? event.callerID;
|
||||||
const callType = event.call_type ?? event.Type;
|
const callType = event.call_type ?? event.Type;
|
||||||
@@ -89,6 +96,12 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
|
|
||||||
if (!ucid) return;
|
if (!ucid) return;
|
||||||
|
|
||||||
|
if (monitorUcid && ucid !== monitorUcid) {
|
||||||
|
this.ucidMap.set(monitorUcid, ucid);
|
||||||
|
this.logger.log(`[UCID-MAP] monitor=${monitorUcid} → agent=${ucid}`);
|
||||||
|
setTimeout(() => this.ucidMap.delete(monitorUcid), 600_000);
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'Answered' || action === 'Calling') {
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
// Don't show calls for offline agents (ghost calls)
|
// Don't show calls for offline agents (ghost calls)
|
||||||
const agentState = this.agentStates.get(agentId);
|
const agentState = this.agentStates.get(agentId);
|
||||||
@@ -163,27 +176,31 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
|
|
||||||
const priorState = this.agentStates.get(agentId)?.state;
|
const priorState = this.agentStates.get(agentId)?.state;
|
||||||
const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
|
const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
|
||||||
if (mapped) {
|
|
||||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
|
||||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
|
||||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
|
||||||
|
|
||||||
// Persist to AgentEvent table. CALL_START/CALL_END are
|
// Persist to AgentEvent table regardless of state mapping.
|
||||||
// handled in handleCallEvent (they arrive via a separate
|
// login returns null for state (UI waits for release/ready) but
|
||||||
// Ozonetel webhook). Everything else is captured here.
|
// the history pipeline needs LOGIN to compute loginDuration.
|
||||||
// Pass priorState so 'release' → RESUME / ACW_END / READY can
|
|
||||||
// be disambiguated for the session rollup.
|
|
||||||
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
||||||
if (historyEventType) {
|
if (historyEventType) {
|
||||||
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
||||||
|
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → eventType=${historyEventType} priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'}`);
|
||||||
this.history.persistAgentEvent({
|
this.history.persistAgentEvent({
|
||||||
ozonetelAgentId: agentId,
|
ozonetelAgentId: agentId,
|
||||||
eventType: historyEventType,
|
eventType: historyEventType,
|
||||||
eventAt: this.parseOzonetelTime(eventTime),
|
eventAt: this.parseOzonetelTime(eventTime),
|
||||||
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
||||||
}).catch(() => {});
|
}).catch((err) => {
|
||||||
|
this.logger.warn(`[AGENT-HISTORY] Failed to persist ${historyEventType} for ${agentId}: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → no history event (priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mapped) {
|
||||||
|
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||||
|
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||||
|
this.logger.log(`[AGENT-STATE] ${agentId} ${priorState ?? 'none'} → ${mapped} (action=${action})`);
|
||||||
|
|
||||||
// Layer 3: ACW auto-dispose safety net
|
// Layer 3: ACW auto-dispose safety net
|
||||||
if (mapped === 'acw') {
|
if (mapped === 'acw') {
|
||||||
// Find the most recent UCID for this agent
|
// Find the most recent UCID for this agent
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ export class MissedCallWebhookController {
|
|||||||
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
||||||
const agentName = payload.AgentName ?? null;
|
const agentName = payload.AgentName ?? null;
|
||||||
const recordingUrl = payload.AudioFile ?? null;
|
const recordingUrl = payload.AudioFile ?? null;
|
||||||
const ucid = payload.monitorUCID ?? null;
|
const monitorUcid = payload.monitorUCID ?? null;
|
||||||
|
// Resolve agent-side UCID from real-time event mapping.
|
||||||
|
// The dispose endpoint creates Call records with the agent UCID;
|
||||||
|
// this lets us find and enrich that record instead of duplicating.
|
||||||
|
const agentUcid = monitorUcid ? this.supervisor.resolveAgentUcid(monitorUcid) : null;
|
||||||
|
const ucid = agentUcid ?? monitorUcid;
|
||||||
|
if (agentUcid) {
|
||||||
|
this.logger.log(`[WEBHOOK] Resolved monitorUCID ${monitorUcid} → agent UCID ${agentUcid}`);
|
||||||
|
}
|
||||||
const disposition = payload.Disposition ?? null;
|
const disposition = payload.Disposition ?? null;
|
||||||
const hangupBy = payload.HangupBy ?? null;
|
const hangupBy = payload.HangupBy ?? null;
|
||||||
|
|
||||||
@@ -109,24 +117,54 @@ export class MissedCallWebhookController {
|
|||||||
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Create call record with leadId + leadName baked in so
|
// Step 2: For answered calls, the dispose endpoint creates the
|
||||||
// the worklist row renders the patient name immediately.
|
// Call record ~5s before this webhook fires. Check if it already
|
||||||
const callId = await this.createCall({
|
// exists and enrich it instead of creating a duplicate.
|
||||||
callerPhone,
|
let callId: string;
|
||||||
direction,
|
if (callStatus === 'COMPLETED' && ucid) {
|
||||||
callStatus,
|
const existing = await this.platform.queryWithAuth<any>(
|
||||||
agentName,
|
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id } } } }`,
|
||||||
startTime,
|
undefined, authHeader,
|
||||||
endTime,
|
).catch(() => null);
|
||||||
duration,
|
const existingId = existing?.calls?.edges?.[0]?.node?.id;
|
||||||
recordingUrl,
|
if (existingId) {
|
||||||
disposition,
|
// Enrich existing record with webhook data (recording, chain name, timing)
|
||||||
ucid,
|
const enrichData: Record<string, any> = {};
|
||||||
leadId: resolved.leadId || null,
|
if (agentName) enrichData.agentName = agentName;
|
||||||
leadName: resolved.leadName,
|
if (recordingUrl) enrichData.recording = { primaryLinkUrl: recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
|
if (resolved.leadId) enrichData.leadId = resolved.leadId;
|
||||||
|
if (resolved.leadName) enrichData.leadName = resolved.leadName;
|
||||||
|
if (startTime) enrichData.startedAt = istToUtc(startTime);
|
||||||
|
if (endTime) enrichData.endedAt = istToUtc(endTime);
|
||||||
|
if (duration) enrichData.durationSec = duration;
|
||||||
|
if (Object.keys(enrichData).length > 0) {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: existingId, data: enrichData },
|
||||||
|
authHeader,
|
||||||
|
).catch(err => this.logger.warn(`[WEBHOOK] Failed to enrich call ${existingId}: ${err}`));
|
||||||
|
}
|
||||||
|
callId = existingId;
|
||||||
|
this.logger.log(`[WEBHOOK] Enriched existing call ${callId} with recording=${recordingUrl ? 'yes' : 'no'} agentName=${agentName}`);
|
||||||
|
} else {
|
||||||
|
// Fallback: dispose didn't create it (edge case) — create normally
|
||||||
|
this.logger.log(`[WEBHOOK] No existing call found for ucid=${ucid} — creating new record`);
|
||||||
|
callId = await this.createCall({
|
||||||
|
callerPhone, direction, callStatus, agentName,
|
||||||
|
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||||
|
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Missed calls — always create (no dispose fires for unanswered)
|
||||||
|
callId = await this.createCall({
|
||||||
|
callerPhone, direction, callStatus, agentName,
|
||||||
|
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||||
|
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||||
|
}, authHeader);
|
||||||
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Push worklist SSE so agents see new calls instantly
|
// Push worklist SSE so agents see new calls instantly
|
||||||
// instead of waiting for the 30s frontend poll.
|
// instead of waiting for the 30s frontend poll.
|
||||||
|
|||||||
Reference in New Issue
Block a user