mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
2 Commits
2d8308bed8
...
v0.10-apr-
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cf0f69dde | |||
| a6f4c51ca9 |
@@ -972,4 +972,110 @@ export class MaintController {
|
||||
this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`);
|
||||
return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons };
|
||||
}
|
||||
|
||||
// Backfill disposition + SLA timing on historical calls using CDR data.
|
||||
// Walks calls from a given date (IST), joins to CDR by UCID, and patches
|
||||
// disposition (from CDR's mapped value) + timing fields. Idempotent —
|
||||
// only overwrites null fields (disposition is always overwritten since
|
||||
// the webhook default is unreliable).
|
||||
@Post('backfill-call-disposition-timing')
|
||||
async backfillCallDispositionTiming(@Body() body: { date?: string }) {
|
||||
const date = body.date ?? new Date(Date.now() + 5.5 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
this.logger.log(`[MAINT] Backfill disposition+timing for date=${date}`);
|
||||
|
||||
// Fetch CDR for the date
|
||||
const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []);
|
||||
if (cdrRows.length === 0) return { status: 'ok', date, scanned: 0, patched: 0, skipped: 0 };
|
||||
|
||||
// Build UCID + monitorUCID map
|
||||
const byUcid = new Map<string, any>();
|
||||
for (const row of cdrRows) {
|
||||
const ucid = String(row.UCID ?? '').trim();
|
||||
const monUcid = String(row.monitorUCID ?? '').trim();
|
||||
if (ucid) byUcid.set(ucid, row);
|
||||
if (monUcid && monUcid !== ucid) byUcid.set(monUcid, row);
|
||||
}
|
||||
|
||||
// Fetch calls for the date that have a UCID
|
||||
const gte = `${date}T00:00:00+05:30`;
|
||||
const lte = `${date}T23:59:59+05:30`;
|
||||
const callsData = await this.platform.query<any>(
|
||||
`{ calls(first: 500, filter: {
|
||||
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||
ucid: { is: NOT_NULL }
|
||||
}) { edges { node {
|
||||
id ucid disposition assignedAt answeredAt responseTimeS startedAt
|
||||
} } } }`,
|
||||
).catch(() => ({ calls: { edges: [] } }));
|
||||
|
||||
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
let patched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const dispositionMap: Record<string, string> = {
|
||||
'General Enquiry': 'INFO_PROVIDED',
|
||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||
'Not Interested': 'NOT_INTERESTED',
|
||||
'Wrong Number': 'WRONG_NUMBER',
|
||||
'No Answer': 'NO_ANSWER',
|
||||
};
|
||||
|
||||
const parseHms = (hms: string | null | undefined): number | null => {
|
||||
if (!hms) return null;
|
||||
const parts = String(hms).split(':').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return null;
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
};
|
||||
|
||||
for (const call of calls) {
|
||||
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||
if (!cdrRow) { skipped++; continue; }
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
|
||||
// Disposition — always overwrite (webhook default is unreliable)
|
||||
const cdrDisp = dispositionMap[cdrRow.Disposition] ?? null;
|
||||
if (cdrDisp) patch.disposition = cdrDisp;
|
||||
|
||||
// Timing — only fill if null
|
||||
if (!call.answeredAt && cdrRow.AnswerTime) {
|
||||
patch.answeredAt = new Date(cdrRow.AnswerTime).toISOString();
|
||||
}
|
||||
if (!call.assignedAt && cdrRow.StartTime) {
|
||||
patch.assignedAt = new Date(cdrRow.StartTime).toISOString();
|
||||
}
|
||||
if (!call.responseTimeS && call.startedAt && (patch.answeredAt || call.answeredAt)) {
|
||||
const start = new Date(call.startedAt).getTime();
|
||||
const answered = new Date(patch.answeredAt ?? call.answeredAt).getTime();
|
||||
if (!isNaN(start) && !isNaN(answered)) {
|
||||
patch.responseTimeS = Math.max(0, Math.round((answered - start) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// CDR timing fields
|
||||
const handlingSec = parseHms(cdrRow.HandlingTime);
|
||||
const wrapupSec = parseHms(cdrRow.WrapupDuration);
|
||||
const holdSec = parseHms(cdrRow.HoldDuration);
|
||||
if (handlingSec !== null) patch.handlingTimeS = handlingSec;
|
||||
if (wrapupSec !== null) patch.acwDurationS = wrapupSec;
|
||||
if (holdSec !== null) patch.holdDurationS = holdSec;
|
||||
|
||||
if (Object.keys(patch).length === 0) { skipped++; continue; }
|
||||
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patch },
|
||||
);
|
||||
patched++;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[MAINT] Backfill patch failed for ${call.id}: ${err.message}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Disposition+timing backfill complete: date=${date} scanned=${calls.length} patched=${patched} skipped=${skipped}`);
|
||||
return { status: 'ok', date, scanned: calls.length, patched, skipped };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,34 @@ export class OzonetelAgentController {
|
||||
}
|
||||
}
|
||||
|
||||
// Update disposition on answered inbound calls. The webhook creates
|
||||
// the Call record with the Ozonetel default disposition ("General
|
||||
// Enquiry" → INFO_PROVIDED) before the agent disposes. Now that the
|
||||
// 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
|
||||
try {
|
||||
await this.missedQueue.assignNext(agentId);
|
||||
|
||||
@@ -52,4 +52,18 @@ export class SupervisorController {
|
||||
} as MessageEvent)),
|
||||
);
|
||||
}
|
||||
|
||||
// Worklist SSE — broadcast to all connected agents. When a missed
|
||||
// call is created by the webhook, this fires immediately so agents
|
||||
// don't wait for the 30s worklist poll. The payload includes the
|
||||
// caller's phone + name for a toast notification.
|
||||
@Sse('worklist/stream')
|
||||
streamWorklistUpdates(): Observable<MessageEvent> {
|
||||
this.logger.log('[SSE] Worklist stream opened');
|
||||
return this.supervisor.worklistSubject.pipe(
|
||||
map(event => ({
|
||||
data: JSON.stringify(event),
|
||||
} as MessageEvent)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ export class SupervisorService implements OnModuleInit {
|
||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
||||
// Worklist update stream — emitted when a missed call is created or
|
||||
// assigned. Frontend SSE listener triggers an immediate worklist
|
||||
// refresh so agents see new missed calls without waiting for the 30s poll.
|
||||
readonly worklistSubject = new Subject<{ type: string; callerPhone?: string; callerName?: string; callId?: string; timestamp: string }>();
|
||||
|
||||
emitWorklistUpdate(data: { type: string; callerPhone?: string; callerName?: string; callId?: string }) {
|
||||
this.worklistSubject.next({ ...data, timestamp: new Date().toISOString() });
|
||||
this.logger.log(`[WORKLIST-SSE] ${data.type} phone=${data.callerPhone ?? '?'} name=${data.callerName ?? '?'}`);
|
||||
}
|
||||
|
||||
// Barge session tracking — key is agentId
|
||||
private readonly bargeSessions = new Map<string, {
|
||||
@@ -101,6 +110,22 @@ export class SupervisorService implements OnModuleInit {
|
||||
eventType: 'CALL_START',
|
||||
eventAt: iso,
|
||||
}).catch(() => {});
|
||||
|
||||
// Write answeredAt + responseTimeS to the Call record.
|
||||
// Look up the Call by UCID, then patch. The "Calling" event
|
||||
// sets assignedAt (ring start); "Answered" computes response
|
||||
// time as answered - assigned (queue wait time).
|
||||
this.patchCallTimingByUcid(ucid, {
|
||||
answeredAt: iso,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// "Calling" = agent's phone is ringing → write assignedAt
|
||||
// (the moment the call was routed to this agent).
|
||||
if (action === 'Calling') {
|
||||
this.patchCallTimingByUcid(ucid, {
|
||||
assignedAt: iso,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (action === 'Disconnect') {
|
||||
const wasActive = this.activeCalls.get(ucid);
|
||||
@@ -306,6 +331,50 @@ export class SupervisorService implements OnModuleInit {
|
||||
return Array.from(this.activeCalls.values());
|
||||
}
|
||||
|
||||
// Look up a Call by UCID and patch its timing fields. Used by
|
||||
// handleCallEvent to write assignedAt/answeredAt in real-time.
|
||||
// Also computes responseTimeS when answeredAt is written and
|
||||
// the Call already has a startedAt.
|
||||
private async patchCallTimingByUcid(ucid: string, fields: {
|
||||
assignedAt?: string;
|
||||
answeredAt?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id startedAt assignedAt } } } }`,
|
||||
);
|
||||
const call = data?.calls?.edges?.[0]?.node;
|
||||
if (!call) {
|
||||
this.logger.warn(`[SLA] No Call for ucid=${ucid} — timing not written`);
|
||||
return;
|
||||
}
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
if (fields.assignedAt) patch.assignedAt = fields.assignedAt;
|
||||
if (fields.answeredAt) {
|
||||
patch.answeredAt = fields.answeredAt;
|
||||
// Compute response time: answered - started (how long the
|
||||
// caller waited from call creation to agent pickup).
|
||||
const start = call.startedAt ? new Date(call.startedAt).getTime() : null;
|
||||
const answered = new Date(fields.answeredAt).getTime();
|
||||
if (start && !isNaN(start) && !isNaN(answered)) {
|
||||
const responseS = Math.max(0, Math.round((answered - start) / 1000));
|
||||
patch.responseTimeS = responseS;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patch },
|
||||
);
|
||||
this.logger.log(`[SLA] Patched call ${call.id} — ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[SLA] patchCallTimingByUcid failed for ${ucid}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamPerformance(date: string): Promise<any> {
|
||||
// Get all agents from platform. Field names are label-derived
|
||||
// camelCase on the current platform schema — see
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||
import { Controller, Post, Body, Headers, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
@@ -24,6 +25,7 @@ export class MissedCallWebhookController {
|
||||
private readonly config: ConfigService,
|
||||
private readonly caller: CallerResolutionService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
@Inject(forwardRef(() => SupervisorService)) private readonly supervisor: SupervisorService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
@@ -126,6 +128,15 @@ export class MissedCallWebhookController {
|
||||
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||
|
||||
// Push worklist SSE so agents see new calls instantly
|
||||
// instead of waiting for the 30s frontend poll.
|
||||
this.supervisor.emitWorklistUpdate({
|
||||
type: callStatus === 'MISSED' ? 'missed-call' : 'inbound-call',
|
||||
callerPhone: callerPhone,
|
||||
callerName: resolved.leadName ?? undefined,
|
||||
callId,
|
||||
});
|
||||
|
||||
// Step 3: Lead-side side-effects (activity log + contact stats)
|
||||
if (resolved.leadId) {
|
||||
const summary = callStatus === 'MISSED'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { WorklistController } from './worklist.controller';
|
||||
import { WorklistService } from './worklist.service';
|
||||
@@ -12,7 +13,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule)],
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule), forwardRef(() => SupervisorModule)],
|
||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
||||
exports: [MissedQueueService],
|
||||
|
||||
Reference in New Issue
Block a user