mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
Defect 5: Worklist, missed-call-webhook, missed-queue, ai-chat, and rules-engine all used legacy lowercase field names (callbackstatus, callsourcenumber, missedcallcount, callbackattemptedat) from the old VPS schema. Fixed to camelCase (callbackStatus, callSourceNumber, missedCallCount, callbackAttemptedAt) matching the current SDK sync. Defect 6: Dial endpoint used global defaults (OZONETEL_AGENT_ID env var) instead of the logged-in agent's config. Now accepts agentId and campaignName from the frontend request body. Falls back to telephony config → DID-derived campaign name → explicit error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
9.8 KiB
TypeScript
243 lines
9.8 KiB
TypeScript
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
|
function istToUtc(istDateStr: string | null): string | null {
|
|
if (!istDateStr) return null;
|
|
// Parse as-is, then subtract 5:30 to get UTC
|
|
const d = new Date(istDateStr);
|
|
if (isNaN(d.getTime())) return null;
|
|
d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30
|
|
return d.toISOString();
|
|
}
|
|
|
|
@Controller('webhooks/ozonetel')
|
|
export class MissedCallWebhookController {
|
|
private readonly logger = new Logger(MissedCallWebhookController.name);
|
|
private readonly apiKey: string;
|
|
|
|
constructor(
|
|
private readonly platform: PlatformGraphqlService,
|
|
private readonly config: ConfigService,
|
|
) {
|
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
|
}
|
|
|
|
@Post('missed-call')
|
|
async handleCallWebhook(@Body() body: Record<string, any>) {
|
|
// Ozonetel sends the payload as a JSON string inside a "data" field
|
|
let payload: Record<string, any>;
|
|
try {
|
|
payload = typeof body.data === 'string' ? JSON.parse(body.data) : body;
|
|
} catch {
|
|
payload = body;
|
|
}
|
|
|
|
this.logger.log(`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`);
|
|
|
|
const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, '');
|
|
const status = payload.Status; // NotAnswered, Answered, Abandoned
|
|
const type = payload.Type; // InBound, OutBound
|
|
const startTime = payload.StartTime;
|
|
const endTime = payload.EndTime;
|
|
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
|
const agentName = payload.AgentName ?? null;
|
|
const recordingUrl = payload.AudioFile ?? null;
|
|
const ucid = payload.monitorUCID ?? null;
|
|
const disposition = payload.Disposition ?? null;
|
|
const hangupBy = payload.HangupBy ?? null;
|
|
|
|
if (!callerPhone) {
|
|
this.logger.warn('No caller phone in webhook — skipping');
|
|
return { received: true, processed: false };
|
|
}
|
|
|
|
// Determine call status for our platform
|
|
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
|
const direction = type === 'InBound' ? 'INBOUND' : 'OUTBOUND';
|
|
|
|
// Use API key auth for server-to-server writes
|
|
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
|
if (!authHeader) {
|
|
this.logger.warn('No PLATFORM_API_KEY configured — cannot write call records');
|
|
return { received: true, processed: false };
|
|
}
|
|
|
|
try {
|
|
// Step 1: Create call record
|
|
const callId = await this.createCall({
|
|
callerPhone,
|
|
direction,
|
|
callStatus,
|
|
agentName,
|
|
startTime,
|
|
endTime,
|
|
duration,
|
|
recordingUrl,
|
|
disposition,
|
|
ucid,
|
|
}, authHeader);
|
|
|
|
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
|
|
|
// Step 2: Find matching lead by phone number
|
|
const lead = await this.findLeadByPhone(callerPhone, authHeader);
|
|
|
|
if (lead) {
|
|
// Step 3: Link call to lead
|
|
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
|
|
|
// Step 4: Create lead activity
|
|
const summary = callStatus === 'MISSED'
|
|
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
|
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
|
|
|
await this.createLeadActivity({
|
|
leadId: lead.id,
|
|
activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
|
summary,
|
|
channel: 'PHONE',
|
|
performedBy: agentName ?? 'System',
|
|
durationSeconds: duration,
|
|
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
|
}, authHeader);
|
|
|
|
// Step 5: Update lead contact timestamps
|
|
await this.updateLead(lead.id, {
|
|
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
|
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
|
}, authHeader);
|
|
|
|
this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`);
|
|
} else {
|
|
this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`);
|
|
}
|
|
|
|
return { received: true, processed: true, callId, leadId: lead?.id ?? null };
|
|
} catch (err: any) {
|
|
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
|
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
|
return { received: true, processed: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
private async createCall(data: {
|
|
callerPhone: string;
|
|
direction: string;
|
|
callStatus: string;
|
|
agentName: string | null;
|
|
startTime: string | null;
|
|
endTime: string | null;
|
|
duration: number;
|
|
recordingUrl: string | null;
|
|
disposition: string | null;
|
|
ucid: string | null;
|
|
}, authHeader: string): Promise<string> {
|
|
const callData: Record<string, any> = {
|
|
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
|
direction: data.direction,
|
|
callStatus: data.callStatus,
|
|
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
|
agentName: data.agentName,
|
|
startedAt: istToUtc(data.startTime),
|
|
endedAt: istToUtc(data.endTime),
|
|
durationSec: data.duration,
|
|
disposition: this.mapDisposition(data.disposition),
|
|
};
|
|
// Set callback tracking fields for missed calls so they appear in the worklist
|
|
if (data.callStatus === 'MISSED') {
|
|
callData.callbackStatus = 'PENDING_CALLBACK';
|
|
callData.missedCallCount = 1;
|
|
}
|
|
if (data.recordingUrl) {
|
|
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
|
}
|
|
|
|
const result = await this.platform.queryWithAuth<any>(
|
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
|
{ data: callData },
|
|
authHeader,
|
|
);
|
|
return result.createCall.id;
|
|
}
|
|
|
|
private async findLeadByPhone(phone: string, authHeader: string): Promise<{ id: string; name: string; contactAttempts: number } | null> {
|
|
const result = await this.platform.queryWithAuth<any>(
|
|
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`,
|
|
undefined,
|
|
authHeader,
|
|
);
|
|
const leads = result.leads.edges.map((e: any) => e.node);
|
|
const cleanPhone = phone.replace(/\D/g, '');
|
|
|
|
return leads.find((l: any) => {
|
|
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
|
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
|
}) ?? null;
|
|
}
|
|
|
|
private async updateCall(callId: string, data: Record<string, any>, authHeader: string): Promise<void> {
|
|
await this.platform.queryWithAuth<any>(
|
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
|
{ id: callId, data },
|
|
authHeader,
|
|
);
|
|
}
|
|
|
|
private async createLeadActivity(data: {
|
|
leadId: string;
|
|
activityType: string;
|
|
summary: string;
|
|
channel: string;
|
|
performedBy: string;
|
|
durationSeconds: number;
|
|
outcome: string;
|
|
}, authHeader: string): Promise<void> {
|
|
await this.platform.queryWithAuth<any>(
|
|
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
|
{
|
|
data: {
|
|
name: data.summary.substring(0, 80),
|
|
activityType: data.activityType,
|
|
summary: data.summary,
|
|
occurredAt: new Date().toISOString(),
|
|
performedBy: data.performedBy,
|
|
channel: data.channel,
|
|
durationSec: data.durationSeconds,
|
|
outcome: data.outcome,
|
|
leadId: data.leadId,
|
|
},
|
|
},
|
|
authHeader,
|
|
);
|
|
}
|
|
|
|
private async updateLead(leadId: string, data: Record<string, any>, authHeader: string): Promise<void> {
|
|
await this.platform.queryWithAuth<any>(
|
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
|
{ id: leadId, data },
|
|
authHeader,
|
|
);
|
|
}
|
|
|
|
private parseDuration(timeStr: string): number {
|
|
const parts = timeStr.split(':').map(Number);
|
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
return parseInt(timeStr) || 0;
|
|
}
|
|
|
|
private mapDisposition(disposition: string | null): string | null {
|
|
if (!disposition) return null;
|
|
const map: Record<string, string> = {
|
|
'General Enquiry': 'INFO_PROVIDED',
|
|
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
|
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
|
'Not Interested': 'CALLBACK_REQUESTED',
|
|
'Wrong Number': 'WRONG_NUMBER',
|
|
};
|
|
return map[disposition] ?? null;
|
|
}
|
|
}
|