mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: SSE agent state, maint module, timestamp fix, missed call lead lookup
- SSE agent state stream: supervisor maintains state map from Ozonetel webhooks, streams via /api/supervisor/agent-state/stream - Force-logout via SSE: distinct force-logout event type avoids conflict with normal login cycle - Maint module (/api/maint): OTP-guarded endpoints for force-ready, unlock-agent, backfill-missed-calls, fix-timestamps - Fix Ozonetel IST→UTC timestamp conversion: istToUtc() in webhook controller and missed-queue service - Missed call lead lookup: ingestion queries leads by phone, stores leadId + leadName on Call entity - Timestamp backfill endpoint: throttled at 700ms/mutation, idempotent (skips already-fixed records) - Structured logging: full JSON payloads for agent/call webhooks, [DISPOSE] trace with agentId - Fix dead code: agent-state endpoint auto-assign was after return statement - Export SupervisorService for cross-module injection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,15 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
export function istToUtc(istDateStr: string | null): string | null {
|
||||
if (!istDateStr) return null;
|
||||
const d = new Date(istDateStr);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
// Normalize phone to +91XXXXXXXXXX format
|
||||
export function normalizePhone(raw: string): string {
|
||||
let digits = raw.replace(/[^0-9]/g, '');
|
||||
@@ -61,9 +70,31 @@ export class MissedQueueService implements OnModuleInit {
|
||||
if (!phone || phone.length < 13) continue;
|
||||
|
||||
const did = call.did || '';
|
||||
const callTime = call.callTime || new Date().toISOString();
|
||||
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Look up lead by phone number — strip +91 prefix for flexible matching
|
||||
const phoneDigits = phone.replace(/^\+91/, '');
|
||||
let leadId: string | null = null;
|
||||
let leadName: string | null = null;
|
||||
try {
|
||||
const leadResult = await this.platform.query<any>(
|
||||
`{ leads(first: 1, filter: {
|
||||
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
||||
}) { edges { node { id contactName { firstName lastName } patientId } } } }`,
|
||||
);
|
||||
const matchedLead = leadResult?.leads?.edges?.[0]?.node;
|
||||
if (matchedLead) {
|
||||
leadId = matchedLead.id;
|
||||
const fn = matchedLead.contactName?.firstName ?? '';
|
||||
const ln = matchedLead.contactName?.lastName ?? '';
|
||||
leadName = `${fn} ${ln}`.trim() || null;
|
||||
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName})`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lead lookup failed for ${phone}: ${err}`);
|
||||
}
|
||||
|
||||
const existing = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
callbackstatus: { eq: PENDING_CALLBACK },
|
||||
@@ -75,29 +106,35 @@ export class MissedQueueService implements OnModuleInit {
|
||||
|
||||
if (existingNode) {
|
||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
||||
const updateParts = [
|
||||
`missedcallcount: ${newCount}`,
|
||||
`startedAt: "${callTime}"`,
|
||||
`callsourcenumber: "${did}"`,
|
||||
];
|
||||
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
||||
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${existingNode.id}", data: {
|
||||
missedcallcount: ${newCount},
|
||||
startedAt: "${callTime}",
|
||||
callsourcenumber: "${did}"
|
||||
}) { id } }`,
|
||||
`mutation { updateCall(id: "${existingNode.id}", data: { ${updateParts.join(', ')} }) { id } }`,
|
||||
);
|
||||
updated++;
|
||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`);
|
||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
||||
} else {
|
||||
const dataParts = [
|
||||
`callStatus: MISSED`,
|
||||
`direction: INBOUND`,
|
||||
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
||||
`callsourcenumber: "${did}"`,
|
||||
`callbackstatus: PENDING_CALLBACK`,
|
||||
`missedcallcount: 1`,
|
||||
`startedAt: "${callTime}"`,
|
||||
];
|
||||
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
||||
if (leadName) dataParts.push(`leadName: "${leadName}"`);
|
||||
await this.platform.query<any>(
|
||||
`mutation { createCall(data: {
|
||||
callStatus: MISSED,
|
||||
direction: INBOUND,
|
||||
callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" },
|
||||
callsourcenumber: "${did}",
|
||||
callbackstatus: PENDING_CALLBACK,
|
||||
missedcallcount: 1,
|
||||
startedAt: "${callTime}"
|
||||
}) { id } }`,
|
||||
`mutation { createCall(data: { ${dataParts.join(', ')} }) { id } }`,
|
||||
);
|
||||
created++;
|
||||
this.logger.log(`Created missed call record for ${phone}`);
|
||||
this.logger.log(`Created missed call record for ${phone}${leadName ? ` → ${leadName}` : ''}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
||||
|
||||
Reference in New Issue
Block a user