mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: Ozonetel webhook handler — create call records from call events
- Parse Ozonetel POST payload (CallerID, Status, Duration, Recording URL) - Create Call record in platform via API key auth - Match caller to Lead by phone number - Create LeadActivity timeline entry - Update lead contactAttempts and lastContacted - Map Ozonetel dispositions to platform enum values Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,223 @@
|
||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@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 handleMissedCall(@Body() body: Record<string, any>) {
|
||||
this.logger.log(`Received missed call webhook: ${JSON.stringify(body)}`);
|
||||
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;
|
||||
}
|
||||
|
||||
// TODO: Auto-assignment, duplicate merging, worklist insertion
|
||||
// For now, just acknowledge receipt
|
||||
this.logger.log(`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`);
|
||||
|
||||
return { received: true };
|
||||
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) {
|
||||
this.logger.error(`Webhook processing failed: ${err}`);
|
||||
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 result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||
direction: data.direction,
|
||||
callStatus: data.callStatus,
|
||||
agentName: data.agentName,
|
||||
startedAt: data.startTime ? new Date(data.startTime).toISOString() : null,
|
||||
endedAt: data.endTime ? new Date(data.endTime).toISOString() : null,
|
||||
durationSec: data.duration,
|
||||
disposition: this.mapDisposition(data.disposition),
|
||||
recordingUrl: data.recordingUrl ? { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' } : undefined,
|
||||
},
|
||||
},
|
||||
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,
|
||||
durationSeconds: 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user