mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +00:00
Compare commits
13 Commits
feature/om
...
44f1ec36e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 44f1ec36e1 | |||
| 4bd08a9b02 | |||
| 0248c4cad1 | |||
| be505b8d1f | |||
| dbefa9675a | |||
| 9dc02e107a | |||
| c807cf737f | |||
| 96d0c32000 | |||
| 9665500b63 | |||
| 9f5935e417 | |||
| 898ff65951 | |||
| 7717536622 | |||
| 33dc8b5669 |
49
.woodpecker.yml
Normal file
49
.woodpecker.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
# Woodpecker CI pipeline for Helix Engage Server (sidecar)
|
||||
#
|
||||
# Runs unit tests. Teams notification on completion.
|
||||
|
||||
when:
|
||||
- event: [push, manual]
|
||||
|
||||
steps:
|
||||
unit-tests:
|
||||
image: node:20
|
||||
commands:
|
||||
- npm ci
|
||||
- npm test -- --ci --forceExit
|
||||
|
||||
notify-teams:
|
||||
image: curlimages/curl
|
||||
commands:
|
||||
- |
|
||||
if [ "${CI_PIPELINE_STATUS}" = "success" ]; then
|
||||
ICON="✅"
|
||||
else
|
||||
ICON="❌"
|
||||
fi
|
||||
curl -s -X POST "${TEAMS_WEBHOOK}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"type\": \"message\",
|
||||
\"attachments\": [{
|
||||
\"contentType\": \"application/vnd.microsoft.card.adaptive\",
|
||||
\"content\": {
|
||||
\"type\": \"AdaptiveCard\",
|
||||
\"version\": \"1.4\",
|
||||
\"body\": [
|
||||
{\"type\": \"TextBlock\", \"size\": \"Medium\", \"weight\": \"Bolder\", \"text\": \"${ICON} Helix Engage Server — Pipeline #${CI_PIPELINE_NUMBER}\"},
|
||||
{\"type\": \"TextBlock\", \"text\": \"**Branch:** ${CI_COMMIT_BRANCH}\", \"wrap\": true},
|
||||
{\"type\": \"TextBlock\", \"text\": \"**Status:** ${CI_PIPELINE_STATUS}\", \"wrap\": true},
|
||||
{\"type\": \"TextBlock\", \"text\": \"**Commit:** ${CI_COMMIT_MESSAGE}\", \"wrap\": true}
|
||||
],
|
||||
\"actions\": [
|
||||
{\"type\": \"Action.OpenUrl\", \"title\": \"View Pipeline\", \"url\": \"https://operations.healix360.net/repos/2/pipeline/${CI_PIPELINE_NUMBER}\"}
|
||||
]
|
||||
}
|
||||
}]
|
||||
}"
|
||||
environment:
|
||||
TEAMS_WEBHOOK:
|
||||
from_secret: teams_webhook
|
||||
when:
|
||||
- status: [success, failure]
|
||||
@@ -271,7 +271,7 @@ export class AiChatController {
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const data = await platformService.queryWithAuth<any>(
|
||||
`{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackstatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`,
|
||||
`{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackStatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const breached = data.calls.edges
|
||||
|
||||
@@ -21,6 +21,8 @@ import { RulesEngineModule } from './rules-engine/rules-engine.module';
|
||||
import { ConfigThemeModule } from './config/config-theme.module';
|
||||
import { WidgetModule } from './widget/widget.module';
|
||||
import { TeamModule } from './team/team.module';
|
||||
import { MasterdataModule } from './masterdata/masterdata.module';
|
||||
import { TelephonyRegistrationService } from './telephony-registration.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -48,6 +50,8 @@ import { TeamModule } from './team/team.module';
|
||||
ConfigThemeModule,
|
||||
WidgetModule,
|
||||
TeamModule,
|
||||
MasterdataModule,
|
||||
],
|
||||
providers: [TelephonyRegistrationService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -62,7 +62,8 @@ export const DEFAULT_TELEPHONY_CONFIG: TelephonyConfig = {
|
||||
// Field-by-field mapping from legacy env var names to config paths. Used by
|
||||
// the first-boot seeder. Keep in sync with the migration target sites.
|
||||
export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [
|
||||
{ env: 'OZONETEL_AGENT_ID', path: ['ozonetel', 'agentId'] },
|
||||
// OZONETEL_AGENT_ID removed — agentId is per-user on the Agent entity,
|
||||
// not a sidecar-level config. All endpoints require agentId from caller.
|
||||
{ env: 'OZONETEL_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] },
|
||||
{ env: 'OZONETEL_DID', path: ['ozonetel', 'did'] },
|
||||
{ env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
|
||||
import { Body, Controller, HttpException, Post, UseGuards, Logger } from '@nestjs/common';
|
||||
import { MaintGuard } from './maint.guard';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
@@ -22,11 +22,14 @@ export class MaintController {
|
||||
) {}
|
||||
|
||||
@Post('force-ready')
|
||||
async forceReady() {
|
||||
async forceReady(@Body() body: { agentId: string }) {
|
||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||
const agentId = body.agentId;
|
||||
const oz = this.telephony.getConfig().ozonetel;
|
||||
const agentId = oz.agentId || 'agent3';
|
||||
const password = oz.agentPassword || 'Test123$';
|
||||
const sipId = oz.sipId || '521814';
|
||||
const password = oz.agentPassword;
|
||||
if (!password) throw new HttpException('agent password not configured', 400);
|
||||
const sipId = oz.sipId;
|
||||
if (!sipId) throw new HttpException('SIP ID not configured', 400);
|
||||
|
||||
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
||||
|
||||
@@ -48,8 +51,9 @@ export class MaintController {
|
||||
}
|
||||
|
||||
@Post('unlock-agent')
|
||||
async unlockAgent() {
|
||||
const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
||||
async unlockAgent(@Body() body: { agentId: string }) {
|
||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||
const agentId = body.agentId;
|
||||
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||
|
||||
try {
|
||||
|
||||
45
src/masterdata/masterdata.controller.ts
Normal file
45
src/masterdata/masterdata.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Query, Logger } from '@nestjs/common';
|
||||
import { MasterdataService } from './masterdata.service';
|
||||
|
||||
@Controller('api/masterdata')
|
||||
export class MasterdataController {
|
||||
private readonly logger = new Logger(MasterdataController.name);
|
||||
|
||||
constructor(private masterdata: MasterdataService) {}
|
||||
|
||||
@Get('departments')
|
||||
async departments() {
|
||||
return this.masterdata.getDepartments();
|
||||
}
|
||||
|
||||
@Get('doctors')
|
||||
async doctors() {
|
||||
return this.masterdata.getDoctors();
|
||||
}
|
||||
|
||||
@Get('clinics')
|
||||
async clinics() {
|
||||
return this.masterdata.getClinics();
|
||||
}
|
||||
|
||||
// Available time slots for a doctor on a given date.
|
||||
// Computed from DoctorVisitSlot entities (doctor × clinic × dayOfWeek).
|
||||
// Returns 30-min slots within the doctor's visiting window for that day.
|
||||
//
|
||||
// GET /api/masterdata/slots?doctorId=xxx&date=2026-04-15
|
||||
@Get('slots')
|
||||
async slots(
|
||||
@Query('doctorId') doctorId: string,
|
||||
@Query('date') date: string,
|
||||
) {
|
||||
if (!doctorId || !date) return [];
|
||||
return this.masterdata.getAvailableSlots(doctorId, date);
|
||||
}
|
||||
|
||||
// Force cache refresh (admin use)
|
||||
@Get('refresh')
|
||||
async refresh() {
|
||||
await this.masterdata.invalidateAll();
|
||||
return { refreshed: true };
|
||||
}
|
||||
}
|
||||
13
src/masterdata/masterdata.module.ts
Normal file
13
src/masterdata/masterdata.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MasterdataController } from './masterdata.controller';
|
||||
import { MasterdataService } from './masterdata.service';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, AuthModule],
|
||||
controllers: [MasterdataController],
|
||||
providers: [MasterdataService],
|
||||
exports: [MasterdataService],
|
||||
})
|
||||
export class MasterdataModule {}
|
||||
183
src/masterdata/masterdata.service.ts
Normal file
183
src/masterdata/masterdata.service.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
|
||||
// Master data: cached lookups for departments, doctors, clinics.
|
||||
// Fetched from the platform on first request, cached in Redis with TTL.
|
||||
// Frontend dropdowns use these instead of direct GraphQL queries.
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
const KEY_DEPARTMENTS = 'masterdata:departments';
|
||||
const KEY_DOCTORS = 'masterdata:doctors';
|
||||
const KEY_CLINICS = 'masterdata:clinics';
|
||||
|
||||
@Injectable()
|
||||
export class MasterdataService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MasterdataService.name);
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private platform: PlatformGraphqlService,
|
||||
private cache: SessionService,
|
||||
) {
|
||||
this.apiKey = this.config.get<string>('platform.apiKey') ?? process.env.PLATFORM_API_KEY ?? '';
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
// Warm cache on startup
|
||||
try {
|
||||
await this.getDepartments();
|
||||
await this.getDoctors();
|
||||
await this.getClinics();
|
||||
this.logger.log('Master data cache warmed');
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Cache warm failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getDepartments(): Promise<string[]> {
|
||||
const cached = await this.cache.getCache(KEY_DEPARTMENTS);
|
||||
if (cached) return JSON.parse(cached);
|
||||
|
||||
const auth = `Bearer ${this.apiKey}`;
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 500) { edges { node { department } } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
|
||||
const departments = Array.from(new Set(
|
||||
data.doctors.edges
|
||||
.map((e: any) => e.node.department)
|
||||
.filter((d: string) => d && d.trim()),
|
||||
)).sort() as string[];
|
||||
|
||||
await this.cache.setCache(KEY_DEPARTMENTS, JSON.stringify(departments), CACHE_TTL);
|
||||
this.logger.log(`Cached ${departments.length} departments`);
|
||||
return departments;
|
||||
}
|
||||
|
||||
async getDoctors(): Promise<Array<{ id: string; name: string; department: string; qualifications: string }>> {
|
||||
const cached = await this.cache.getCache(KEY_DOCTORS);
|
||||
if (cached) return JSON.parse(cached);
|
||||
|
||||
const auth = `Bearer ${this.apiKey}`;
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 500) { edges { node {
|
||||
id name department qualifications specialty active
|
||||
fullName { firstName lastName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
|
||||
const doctors = data.doctors.edges
|
||||
.map((e: any) => ({
|
||||
id: e.node.id,
|
||||
name: e.node.name ?? `${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(),
|
||||
department: e.node.department ?? '',
|
||||
qualifications: e.node.qualifications ?? '',
|
||||
specialty: e.node.specialty ?? '',
|
||||
active: e.node.active ?? true,
|
||||
}))
|
||||
.filter((d: any) => d.active !== false);
|
||||
|
||||
await this.cache.setCache(KEY_DOCTORS, JSON.stringify(doctors), CACHE_TTL);
|
||||
this.logger.log(`Cached ${doctors.length} doctors`);
|
||||
return doctors;
|
||||
}
|
||||
|
||||
async getClinics(): Promise<Array<{ id: string; name: string; phone: string; address: string; opensAt: string; closesAt: string }>> {
|
||||
const cached = await this.cache.getCache(KEY_CLINICS);
|
||||
if (cached) return JSON.parse(cached);
|
||||
|
||||
const auth = `Bearer ${this.apiKey}`;
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ clinics(first: 50) { edges { node {
|
||||
id clinicName status opensAt closesAt
|
||||
phone { primaryPhoneNumber }
|
||||
addressCustom { addressCity addressState }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
|
||||
const clinics = data.clinics.edges
|
||||
.filter((e: any) => e.node.status !== 'INACTIVE')
|
||||
.map((e: any) => ({
|
||||
id: e.node.id,
|
||||
name: e.node.clinicName ?? '',
|
||||
phone: e.node.phone?.primaryPhoneNumber ?? '',
|
||||
opensAt: e.node.opensAt ?? '08:00',
|
||||
closesAt: e.node.closesAt ?? '20:00',
|
||||
address: [e.node.addressCustom?.addressCity, e.node.addressCustom?.addressState].filter(Boolean).join(', '),
|
||||
}));
|
||||
|
||||
await this.cache.setCache(KEY_CLINICS, JSON.stringify(clinics), CACHE_TTL);
|
||||
this.logger.log(`Cached ${clinics.length} clinics`);
|
||||
return clinics;
|
||||
}
|
||||
|
||||
// Available time slots for a doctor on a given date.
|
||||
// Reads DoctorVisitSlot entities for the matching dayOfWeek,
|
||||
// then generates 30-min slots within each visiting window.
|
||||
async getAvailableSlots(doctorId: string, date: string): Promise<Array<{ time: string; label: string; clinicId: string; clinicName: string }>> {
|
||||
const dayOfWeek = new Date(date).toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase();
|
||||
const cacheKey = `masterdata:slots:${doctorId}:${dayOfWeek}`;
|
||||
|
||||
const cached = await this.cache.getCache(cacheKey);
|
||||
if (cached) return JSON.parse(cached);
|
||||
|
||||
const auth = `Bearer ${this.apiKey}`;
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ doctorVisitSlots(first: 100, filter: { doctorId: { eq: "${doctorId}" }, dayOfWeek: { eq: ${dayOfWeek} } }) {
|
||||
edges { node { id startTime endTime clinic { id clinicName } } }
|
||||
} }`,
|
||||
undefined, auth,
|
||||
);
|
||||
|
||||
const slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }> = [];
|
||||
|
||||
for (const edge of data.doctorVisitSlots?.edges ?? []) {
|
||||
const node = edge.node;
|
||||
const clinicId = node.clinic?.id ?? '';
|
||||
const clinicName = node.clinic?.clinicName ?? '';
|
||||
const startTime = node.startTime ?? '09:00';
|
||||
const endTime = node.endTime ?? '17:00';
|
||||
|
||||
// Generate 30-min slots within visiting window
|
||||
const [startH, startM] = startTime.split(':').map(Number);
|
||||
const [endH, endM] = endTime.split(':').map(Number);
|
||||
let h = startH, m = startM ?? 0;
|
||||
const endMin = endH * 60 + (endM ?? 0);
|
||||
|
||||
while (h * 60 + m < endMin) {
|
||||
const hh = h.toString().padStart(2, '0');
|
||||
const mm = m.toString().padStart(2, '0');
|
||||
const ampm = h < 12 ? 'AM' : 'PM';
|
||||
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
slots.push({
|
||||
time: `${hh}:${mm}`,
|
||||
label: `${displayH}:${mm.toString().padStart(2, '0')} ${ampm} — ${clinicName}`,
|
||||
clinicId,
|
||||
clinicName,
|
||||
});
|
||||
m += 30;
|
||||
if (m >= 60) { h++; m = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
slots.sort((a, b) => a.time.localeCompare(b.time));
|
||||
|
||||
await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL);
|
||||
this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`);
|
||||
return slots;
|
||||
}
|
||||
|
||||
async invalidateAll(): Promise<void> {
|
||||
await this.cache.setCache(KEY_DEPARTMENTS, '', 1);
|
||||
await this.cache.setCache(KEY_DOCTORS, '', 1);
|
||||
await this.cache.setCache(KEY_CLINICS, '', 1);
|
||||
this.logger.log('Master data cache invalidated');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { EventBusService } from '../events/event-bus.service';
|
||||
import { Topics } from '../events/event-types';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
|
||||
@Controller('api/ozonetel')
|
||||
export class OzonetelAgentController {
|
||||
@@ -16,17 +17,12 @@ export class OzonetelAgentController {
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly eventBus: EventBusService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
) {}
|
||||
|
||||
// Read-through accessors so admin updates take effect immediately.
|
||||
private get defaultAgentId(): string {
|
||||
return this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
||||
}
|
||||
private get defaultAgentPassword(): string {
|
||||
return this.telephony.getConfig().ozonetel.agentPassword;
|
||||
}
|
||||
private get defaultSipId(): string {
|
||||
return this.telephony.getConfig().ozonetel.sipId || '521814';
|
||||
private requireAgentId(agentId: string | undefined | null): string {
|
||||
if (!agentId) throw new HttpException('agentId required', 400);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
@Post('agent-login')
|
||||
@@ -65,17 +61,18 @@ export class OzonetelAgentController {
|
||||
|
||||
@Post('agent-state')
|
||||
async agentState(
|
||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||
@Body() body: { agentId: string; state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||
) {
|
||||
if (!body.state) {
|
||||
throw new HttpException('state required', 400);
|
||||
}
|
||||
const agentId = this.requireAgentId(body.agentId);
|
||||
|
||||
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||
this.logger.log(`[AGENT-STATE] ${agentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.changeAgentState({
|
||||
agentId: this.defaultAgentId,
|
||||
agentId,
|
||||
state: body.state,
|
||||
pauseReason: body.pauseReason,
|
||||
});
|
||||
@@ -84,7 +81,7 @@ export class OzonetelAgentController {
|
||||
// Auto-assign missed call when agent goes Ready
|
||||
if (body.state === 'Ready') {
|
||||
try {
|
||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
const assigned = await this.missedQueue.assignNext(agentId);
|
||||
if (assigned) {
|
||||
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||
return { ...result, assignedCall: assigned };
|
||||
@@ -110,6 +107,7 @@ export class OzonetelAgentController {
|
||||
@Body() body: {
|
||||
ucid: string;
|
||||
disposition: string;
|
||||
agentId: string;
|
||||
callerPhone?: string;
|
||||
direction?: string;
|
||||
durationSec?: number;
|
||||
@@ -122,13 +120,17 @@ export class OzonetelAgentController {
|
||||
throw new HttpException('ucid and disposition required', 400);
|
||||
}
|
||||
|
||||
const agentId = this.requireAgentId(body.agentId);
|
||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||
|
||||
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${this.defaultAgentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
||||
// Cancel the ACW auto-dispose timer — the frontend submitted disposition
|
||||
this.supervisor.cancelAcwTimer(agentId);
|
||||
|
||||
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${agentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.setDisposition({
|
||||
agentId: this.defaultAgentId,
|
||||
agentId,
|
||||
ucid: body.ucid,
|
||||
disposition: ozonetelDisposition,
|
||||
});
|
||||
@@ -139,6 +141,37 @@ export class OzonetelAgentController {
|
||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||
}
|
||||
|
||||
// Create call record for outbound calls. Inbound calls are
|
||||
// created by the webhook — but we skip outbound in the webhook
|
||||
// (they're not "missed calls"). So the dispose endpoint is the
|
||||
// only place that creates the call record for outbound dials.
|
||||
if (body.direction === 'OUTBOUND' && body.callerPhone) {
|
||||
try {
|
||||
const callData: Record<string, any> = {
|
||||
name: `Outbound — ${body.callerPhone}`,
|
||||
direction: 'OUTBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
||||
agentName: agentId,
|
||||
durationSec: body.durationSec ?? 0,
|
||||
disposition: body.disposition,
|
||||
};
|
||||
if (body.leadId) callData.leadId = body.leadId;
|
||||
|
||||
const apiKey = process.env.PLATFORM_API_KEY;
|
||||
if (apiKey) {
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||
{ data: callData },
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DISPOSE] Failed to create outbound call record: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle missed call callback status update
|
||||
if (body.missedCallId) {
|
||||
const statusMap: Record<string, string> = {
|
||||
@@ -152,7 +185,7 @@ export class OzonetelAgentController {
|
||||
if (newStatus) {
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackStatus: ${newStatus} }) { id } }`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||
@@ -162,7 +195,7 @@ export class OzonetelAgentController {
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
await this.missedQueue.assignNext(agentId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||
}
|
||||
@@ -171,7 +204,7 @@ export class OzonetelAgentController {
|
||||
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||
callId: null,
|
||||
ucid: body.ucid,
|
||||
agentId: this.defaultAgentId,
|
||||
agentId,
|
||||
callerPhone: body.callerPhone ?? '',
|
||||
direction: body.direction ?? 'INBOUND',
|
||||
durationSec: body.durationSec ?? 0,
|
||||
@@ -186,19 +219,27 @@ export class OzonetelAgentController {
|
||||
|
||||
@Post('dial')
|
||||
async dial(
|
||||
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
||||
@Body() body: { phoneNumber: string; agentId: string; campaignName?: string; leadId?: string },
|
||||
) {
|
||||
if (!body.phoneNumber) {
|
||||
throw new HttpException('phoneNumber required', 400);
|
||||
}
|
||||
|
||||
const campaignName = body.campaignName ?? this.telephony.getConfig().ozonetel.campaignName ?? 'Inbound_918041763265';
|
||||
const agentId = this.requireAgentId(body.agentId);
|
||||
const did = this.telephony.getConfig().ozonetel.did;
|
||||
const campaignName = body.campaignName
|
||||
|| this.telephony.getConfig().ozonetel.campaignName
|
||||
|| (did ? `Inbound_${did}` : '');
|
||||
|
||||
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
||||
if (!campaignName) {
|
||||
throw new HttpException('Campaign name not configured — set in Telephony settings or pass campaignName', 400);
|
||||
}
|
||||
|
||||
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${agentId} lead=${body.leadId ?? 'none'}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.manualDial({
|
||||
agentId: this.defaultAgentId,
|
||||
agentId,
|
||||
campaignName,
|
||||
customerNumber: body.phoneNumber,
|
||||
});
|
||||
@@ -276,23 +317,27 @@ export class OzonetelAgentController {
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
async performance(@Query('date') date?: string) {
|
||||
async performance(@Query('date') date?: string, @Query('agentId') agentId?: string) {
|
||||
const agent = this.requireAgentId(agentId);
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
||||
|
||||
const [cdr, summary, aht] = await Promise.all([
|
||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
||||
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||
this.ozonetelAgent.getAHT(agent),
|
||||
]);
|
||||
|
||||
const totalCalls = cdr.length;
|
||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
||||
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
||||
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
||||
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
||||
const agentCdr = cdr.filter((c: any) => c.AgentID === agent || c.AgentName === agent);
|
||||
|
||||
const talkTimes = cdr
|
||||
const totalCalls = agentCdr.length;
|
||||
const inbound = agentCdr.filter((c: any) => c.Type === 'InBound').length;
|
||||
const outbound = agentCdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||
const answered = agentCdr.filter((c: any) => c.Status === 'Answered').length;
|
||||
const missed = agentCdr.filter((c: any) => c.Status === 'NotAnswered').length;
|
||||
|
||||
const talkTimes = agentCdr
|
||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||
.map((c: any) => {
|
||||
const parts = c.TalkTime.split(':').map(Number);
|
||||
@@ -303,12 +348,12 @@ export class OzonetelAgentController {
|
||||
: 0;
|
||||
|
||||
const dispositions: Record<string, number> = {};
|
||||
for (const c of cdr) {
|
||||
for (const c of agentCdr) {
|
||||
const d = (c as any).Disposition || 'No Disposition';
|
||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const appointmentsBooked = cdr.filter((c: any) =>
|
||||
const appointmentsBooked = agentCdr.filter((c: any) =>
|
||||
c.Disposition?.toLowerCase().includes('appointment'),
|
||||
).length;
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { KookooIvrController } from './kookoo-ivr.controller';
|
||||
import { WorklistModule } from '../worklist/worklist.module';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
||||
imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)],
|
||||
controllers: [OzonetelAgentController, KookooIvrController],
|
||||
providers: [OzonetelAgentService],
|
||||
exports: [OzonetelAgentService],
|
||||
|
||||
@@ -18,10 +18,10 @@ export class CallFactsProvider implements FactProvider {
|
||||
'call.status': call.callStatus ?? null,
|
||||
'call.disposition': call.disposition ?? null,
|
||||
'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0,
|
||||
'call.callbackStatus': call.callbackstatus ?? call.callbackStatus ?? null,
|
||||
'call.callbackStatus': call.callbackStatus ?? call.callbackStatus ?? null,
|
||||
'call.slaElapsedPercent': slaElapsedPercent,
|
||||
'call.slaBreached': slaElapsedPercent > 100,
|
||||
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
|
||||
'call.missedCount': call.missedCallCount ?? call.missedCount ?? 0,
|
||||
'call.taskType': taskType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { SupervisorController } from './supervisor.controller';
|
||||
import { SupervisorService } from './supervisor.service';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, OzonetelAgentModule],
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||
controllers: [SupervisorController],
|
||||
providers: [SupervisorService],
|
||||
exports: [SupervisorService],
|
||||
|
||||
@@ -20,11 +20,20 @@ type AgentStateEntry = {
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// ACW auto-dispose: if an agent has been in ACW for longer than this
|
||||
// without the frontend calling /api/ozonetel/dispose, the server
|
||||
// auto-disposes with a default disposition + autoRelease. This is the
|
||||
// Layer 3 safety net — covers browser crash, tab close, page refresh
|
||||
// where sendBeacon didn't fire, or any other frontend failure.
|
||||
const ACW_TIMEOUT_MS = 30_000; // 30 seconds
|
||||
const ACW_DEFAULT_DISPOSITION = 'General Enquiry';
|
||||
|
||||
@Injectable()
|
||||
export class SupervisorService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SupervisorService.name);
|
||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
||||
|
||||
constructor(
|
||||
@@ -37,6 +46,17 @@ export class SupervisorService implements OnModuleInit {
|
||||
this.logger.log('Supervisor service initialized');
|
||||
}
|
||||
|
||||
// Called by the dispose endpoint to cancel the ACW timer
|
||||
// (agent submitted disposition before the timeout)
|
||||
cancelAcwTimer(agentId: string) {
|
||||
const timer = this.acwTimers.get(agentId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.acwTimers.delete(agentId);
|
||||
this.logger.log(`[ACW-TIMER] Cancelled for ${agentId} (disposition received)`);
|
||||
}
|
||||
}
|
||||
|
||||
handleCallEvent(event: any) {
|
||||
const action = event.action;
|
||||
const ucid = event.ucid ?? event.monitorUCID;
|
||||
@@ -48,6 +68,12 @@ export class SupervisorService implements OnModuleInit {
|
||||
if (!ucid) return;
|
||||
|
||||
if (action === 'Answered' || action === 'Calling') {
|
||||
// Don't show calls for offline agents (ghost calls)
|
||||
const agentState = this.agentStates.get(agentId);
|
||||
if (agentState?.state === 'offline') {
|
||||
this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`);
|
||||
return;
|
||||
}
|
||||
this.activeCalls.set(ucid, {
|
||||
ucid, agentId, callerNumber,
|
||||
callType, startTime: eventTime, status: 'active',
|
||||
@@ -71,6 +97,46 @@ export class SupervisorService implements OnModuleInit {
|
||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
||||
|
||||
// Layer 3: ACW auto-dispose safety net
|
||||
if (mapped === 'acw') {
|
||||
// Find the most recent UCID for this agent
|
||||
const lastCall = Array.from(this.activeCalls.values())
|
||||
.filter(c => c.agentId === agentId)
|
||||
.pop();
|
||||
const ucid = lastCall?.ucid;
|
||||
|
||||
this.cancelAcwTimer(agentId); // clear any existing timer
|
||||
const timer = setTimeout(async () => {
|
||||
// Check if agent is STILL in ACW (they might have disposed by now)
|
||||
const current = this.agentStates.get(agentId);
|
||||
if (current?.state !== 'acw') {
|
||||
this.logger.log(`[ACW-TIMER] ${agentId} no longer in ACW — skipping auto-dispose`);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(`[ACW-TIMER] ${agentId} stuck in ACW for ${ACW_TIMEOUT_MS / 1000}s — auto-disposing${ucid ? ` (UCID ${ucid})` : ''}`);
|
||||
try {
|
||||
if (ucid) {
|
||||
await this.ozonetel.setDisposition({ agentId, ucid, disposition: ACW_DEFAULT_DISPOSITION });
|
||||
} else {
|
||||
await this.ozonetel.changeAgentState({ agentId, state: 'Ready' });
|
||||
}
|
||||
this.logger.log(`[ACW-TIMER] Auto-dispose successful for ${agentId}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[ACW-TIMER] Auto-dispose failed for ${agentId}: ${err.message}`);
|
||||
// Last resort: try force-ready
|
||||
try {
|
||||
await this.ozonetel.changeAgentState({ agentId, state: 'Ready' });
|
||||
} catch {}
|
||||
}
|
||||
this.acwTimers.delete(agentId);
|
||||
}, ACW_TIMEOUT_MS);
|
||||
this.acwTimers.set(agentId, timer);
|
||||
this.logger.log(`[ACW-TIMER] Started ${ACW_TIMEOUT_MS / 1000}s timer for ${agentId}`);
|
||||
} else if (mapped === 'ready' || mapped === 'offline') {
|
||||
// Agent left ACW normally — cancel the timer
|
||||
this.cancelAcwTimer(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,20 +186,52 @@ export class SupervisorService implements OnModuleInit {
|
||||
);
|
||||
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
// Fetch Ozonetel time summary per agent
|
||||
// Fetch CDR for the entire account for this date (one call, not per-agent)
|
||||
let allCdr: any[] = [];
|
||||
try {
|
||||
allCdr = await this.ozonetel.fetchCDR({ date });
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch CDR for ${date}: ${err}`);
|
||||
}
|
||||
|
||||
// Fetch Ozonetel time summary per agent + compute call metrics from CDR
|
||||
const summaries = await Promise.all(
|
||||
agents.map(async (agent: any) => {
|
||||
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null };
|
||||
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null, calls: null };
|
||||
try {
|
||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
||||
return { ...agent, timeBreakdown: summary };
|
||||
|
||||
// Filter CDR to this agent
|
||||
const agentCdr = allCdr.filter(
|
||||
(c: any) => c.AgentID === agent.ozonetelAgentId || c.AgentName === agent.ozonetelAgentId,
|
||||
);
|
||||
const totalCalls = agentCdr.length;
|
||||
const inbound = agentCdr.filter((c: any) => c.Type === 'InBound').length;
|
||||
const outbound = agentCdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||
const answered = agentCdr.filter((c: any) => c.Status === 'Answered').length;
|
||||
const missed = agentCdr.filter((c: any) => c.Status === 'NotAnswered').length;
|
||||
|
||||
return {
|
||||
...agent,
|
||||
timeBreakdown: summary,
|
||||
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
|
||||
return { ...agent, timeBreakdown: null };
|
||||
return { ...agent, timeBreakdown: null, calls: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { date, agents: summaries };
|
||||
// Aggregate team totals
|
||||
const teamTotals = {
|
||||
totalCalls: summaries.reduce((sum, a) => sum + (a.calls?.total ?? 0), 0),
|
||||
inbound: summaries.reduce((sum, a) => sum + (a.calls?.inbound ?? 0), 0),
|
||||
outbound: summaries.reduce((sum, a) => sum + (a.calls?.outbound ?? 0), 0),
|
||||
answered: summaries.reduce((sum, a) => sum + (a.calls?.answered ?? 0), 0),
|
||||
missed: summaries.reduce((sum, a) => sum + (a.calls?.missed ?? 0), 0),
|
||||
};
|
||||
|
||||
return { date, agents: summaries, teamTotals };
|
||||
}
|
||||
}
|
||||
|
||||
114
src/telephony-registration.service.ts
Normal file
114
src/telephony-registration.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { PlatformGraphqlService } from './platform/platform-graphql.service';
|
||||
|
||||
// On startup, registers this sidecar with the telephony dispatcher
|
||||
// so Ozonetel events are routed to the correct sidecar by agentId.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Load agent list from platform (Agent entities in this workspace)
|
||||
// 2. POST /api/supervisor/register to the dispatcher
|
||||
// 3. Start heartbeat interval (every 30s)
|
||||
// 4. On shutdown, DELETE /api/supervisor/register
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
@Injectable()
|
||||
export class TelephonyRegistrationService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(TelephonyRegistrationService.name);
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private platform: PlatformGraphqlService,
|
||||
) {}
|
||||
|
||||
private get dispatcherUrl(): string {
|
||||
return this.config.get<string>('TELEPHONY_DISPATCHER_URL') ?? '';
|
||||
}
|
||||
|
||||
private get sidecarUrl(): string {
|
||||
return this.config.get<string>('TELEPHONY_CALLBACK_URL') ?? '';
|
||||
}
|
||||
|
||||
private get workspace(): string {
|
||||
return process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'unknown';
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (!this.dispatcherUrl || !this.sidecarUrl) {
|
||||
this.logger.warn('TELEPHONY_DISPATCHER_URL or TELEPHONY_CALLBACK_URL not set — skipping telephony registration');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.register();
|
||||
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
try {
|
||||
await axios.post(`${this.dispatcherUrl}/api/supervisor/heartbeat`, {
|
||||
sidecarUrl: this.sidecarUrl,
|
||||
}, { timeout: 5000 });
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Heartbeat failed: ${err.message} — attempting re-registration`);
|
||||
await this.register();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||
|
||||
if (this.dispatcherUrl && this.sidecarUrl) {
|
||||
try {
|
||||
await axios.delete(`${this.dispatcherUrl}/api/supervisor/register`, {
|
||||
data: { sidecarUrl: this.sidecarUrl },
|
||||
timeout: 5000,
|
||||
});
|
||||
this.logger.log('Deregistered from telephony dispatcher');
|
||||
} catch {
|
||||
// Best-effort — TTL will clean up anyway
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async register() {
|
||||
try {
|
||||
const agents = await this.loadAgentIds();
|
||||
if (agents.length === 0) {
|
||||
this.logger.warn('No agents found in workspace — skipping registration');
|
||||
return;
|
||||
}
|
||||
|
||||
await axios.post(`${this.dispatcherUrl}/api/supervisor/register`, {
|
||||
sidecarUrl: this.sidecarUrl,
|
||||
workspace: this.workspace,
|
||||
agents,
|
||||
}, { timeout: 5000 });
|
||||
|
||||
this.logger.log(`Registered with telephony dispatcher: ${agents.length} agents (${agents.join(', ')})`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Registration failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAgentIds(): Promise<string[]> {
|
||||
try {
|
||||
const apiKey = this.config.get<string>('PLATFORM_API_KEY');
|
||||
if (!apiKey) return [];
|
||||
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ agents(first: 50) { edges { node { ozonetelAgentId } } } }`,
|
||||
undefined,
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
|
||||
return (data.agents?.edges ?? [])
|
||||
.map((e: any) => e.node.ozonetelAgentId)
|
||||
.filter((id: string) => id && id !== 'PENDING');
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Failed to load agents from platform: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,17 @@ export class MissedCallWebhookController {
|
||||
return { received: true, processed: false };
|
||||
}
|
||||
|
||||
// Skip outbound calls — an unanswered outbound dial is NOT a
|
||||
// "missed call" in the call-center sense. Outbound call records
|
||||
// are created by the disposition flow, not the webhook.
|
||||
if (type === 'Manual' || type === 'OutBound') {
|
||||
this.logger.log(`Skipping outbound call webhook (type=${type}, status=${status})`);
|
||||
return { received: true, processed: false, reason: 'outbound' };
|
||||
}
|
||||
|
||||
// Determine call status for our platform
|
||||
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
||||
const direction = type === 'InBound' ? 'INBOUND' : 'OUTBOUND';
|
||||
const direction = 'INBOUND'; // only inbound reaches here now
|
||||
|
||||
// Use API key auth for server-to-server writes
|
||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||
@@ -147,8 +155,8 @@ export class MissedCallWebhookController {
|
||||
};
|
||||
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||
if (data.callStatus === 'MISSED') {
|
||||
callData.callbackstatus = 'PENDING_CALLBACK';
|
||||
callData.missedcallcount = 1;
|
||||
callData.callbackStatus = 'PENDING_CALLBACK';
|
||||
callData.missedCallCount = 1;
|
||||
}
|
||||
if (data.recordingUrl) {
|
||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
export function istToUtc(istDateStr: string | null): string | null {
|
||||
@@ -33,10 +34,16 @@ export class MissedQueueService implements OnModuleInit {
|
||||
private readonly config: ConfigService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly ozonetel: OzonetelAgentService,
|
||||
private readonly telephony: TelephonyConfigService,
|
||||
) {
|
||||
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
||||
}
|
||||
|
||||
// Read-through so admin config changes take effect without restart
|
||||
private get ownCampaign(): string {
|
||||
return this.telephony.getConfig().ozonetel.campaignName ?? '';
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
||||
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
|
||||
@@ -61,7 +68,17 @@ export class MissedQueueService implements OnModuleInit {
|
||||
|
||||
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
||||
|
||||
for (const call of abandonCalls) {
|
||||
// Filter to this sidecar's campaign only — the Ozonetel API
|
||||
// returns ALL abandoned calls across the account.
|
||||
const filtered = this.ownCampaign
|
||||
? abandonCalls.filter((c: any) => c.campaign === this.ownCampaign)
|
||||
: abandonCalls;
|
||||
|
||||
if (filtered.length < abandonCalls.length) {
|
||||
this.logger.log(`Filtered ${abandonCalls.length - filtered.length} calls from other campaigns (own=${this.ownCampaign})`);
|
||||
}
|
||||
|
||||
for (const call of filtered) {
|
||||
const ucid = call.monitorUCID;
|
||||
if (!ucid || this.processedUcids.has(ucid)) continue;
|
||||
this.processedUcids.add(ucid);
|
||||
@@ -97,19 +114,19 @@ export class MissedQueueService implements OnModuleInit {
|
||||
|
||||
const existing = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
callbackstatus: { eq: PENDING_CALLBACK },
|
||||
callbackStatus: { eq: PENDING_CALLBACK },
|
||||
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
||||
}) { edges { node { id missedcallcount } } } }`,
|
||||
}) { edges { node { id missedCallCount } } } }`,
|
||||
);
|
||||
|
||||
const existingNode = existing?.calls?.edges?.[0]?.node;
|
||||
|
||||
if (existingNode) {
|
||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
||||
const newCount = (existingNode.missedCallCount || 1) + 1;
|
||||
const updateParts = [
|
||||
`missedcallcount: ${newCount}`,
|
||||
`missedCallCount: ${newCount}`,
|
||||
`startedAt: "${callTime}"`,
|
||||
`callsourcenumber: "${did}"`,
|
||||
`callSourceNumber: "${did}"`,
|
||||
];
|
||||
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
||||
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
||||
@@ -123,9 +140,9 @@ export class MissedQueueService implements OnModuleInit {
|
||||
`callStatus: MISSED`,
|
||||
`direction: INBOUND`,
|
||||
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
||||
`callsourcenumber: "${did}"`,
|
||||
`callbackstatus: PENDING_CALLBACK`,
|
||||
`missedcallcount: 1`,
|
||||
`callSourceNumber: "${did}"`,
|
||||
`callbackStatus: PENDING_CALLBACK`,
|
||||
`missedCallCount: 1`,
|
||||
`startedAt: "${callTime}"`,
|
||||
];
|
||||
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
||||
@@ -160,12 +177,12 @@ export class MissedQueueService implements OnModuleInit {
|
||||
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
||||
let result = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
callbackstatus: { eq: PENDING_CALLBACK },
|
||||
callbackStatus: { eq: PENDING_CALLBACK },
|
||||
agentName: { eq: "" }
|
||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||
edges { node {
|
||||
id callerNumber { primaryPhoneNumber }
|
||||
startedAt callsourcenumber missedcallcount
|
||||
startedAt callSourceNumber missedCallCount
|
||||
} }
|
||||
} }`,
|
||||
);
|
||||
@@ -176,12 +193,12 @@ export class MissedQueueService implements OnModuleInit {
|
||||
if (!call) {
|
||||
result = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: {
|
||||
callbackstatus: { eq: PENDING_CALLBACK },
|
||||
callbackStatus: { eq: PENDING_CALLBACK },
|
||||
agentName: { is: NULL }
|
||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||
edges { node {
|
||||
id callerNumber { primaryPhoneNumber }
|
||||
startedAt callsourcenumber missedcallcount
|
||||
startedAt callSourceNumber missedCallCount
|
||||
} }
|
||||
} }`,
|
||||
);
|
||||
@@ -209,13 +226,13 @@ export class MissedQueueService implements OnModuleInit {
|
||||
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
|
||||
const dataParts: string[] = [`callbackstatus: ${status}`];
|
||||
const dataParts: string[] = [`callbackStatus: ${status}`];
|
||||
if (status === 'CALLBACK_ATTEMPTED') {
|
||||
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
||||
dataParts.push(`callbackAttemptedAt: "${new Date().toISOString()}"`);
|
||||
}
|
||||
|
||||
return this.platform.queryWithAuth<any>(
|
||||
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackstatus callbackattemptedat } }`,
|
||||
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackStatus callbackAttemptedAt } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
@@ -230,12 +247,12 @@ export class MissedQueueService implements OnModuleInit {
|
||||
const fields = `id name createdAt direction callStatus agentName
|
||||
callerNumber { primaryPhoneNumber }
|
||||
startedAt endedAt durationSec disposition leadId
|
||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
||||
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt`;
|
||||
|
||||
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
||||
agentName: { eq: "${agentName}" },
|
||||
callStatus: { eq: MISSED },
|
||||
callbackstatus: { eq: ${status} }
|
||||
callbackStatus: { eq: ${status} }
|
||||
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { MissedQueueService, istToUtc, normalizePhone } from './missed-queue.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { ABANDON_CALL_RECORD } from '../__fixtures__/ozonetel-payloads';
|
||||
|
||||
describe('MissedQueueService', () => {
|
||||
@@ -57,6 +58,16 @@ describe('MissedQueueService', () => {
|
||||
getAbandonCalls: jest.fn().mockResolvedValue([ABANDON_CALL_RECORD]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TelephonyConfigService,
|
||||
useValue: {
|
||||
getConfig: () => ({
|
||||
ozonetel: { campaignName: 'Inbound_918041763400', agentId: '', agentPassword: '', did: '918041763400', sipId: '' },
|
||||
sip: { domain: 'test', wsPort: '444' },
|
||||
exotel: { apiKey: '', accountSid: '', subdomain: '' },
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PlatformModule } from '../platform/platform.module';
|
||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { WorklistController } from './worklist.controller';
|
||||
import { WorklistService } from './worklist.service';
|
||||
import { MissedQueueService } from './missed-queue.service';
|
||||
@@ -12,7 +13,7 @@ import { KookooCallbackController } from './kookoo-callback.controller';
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||
providers: [WorklistService, MissedQueueService],
|
||||
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
||||
exports: [MissedQueueService],
|
||||
})
|
||||
export class WorklistModule {}
|
||||
|
||||
@@ -97,13 +97,13 @@ export class WorklistService {
|
||||
try {
|
||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ calls(first: 20, filter: { callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||||
`{ calls(first: 20, filter: { callStatus: { eq: MISSED }, callbackStatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
direction callStatus agentName
|
||||
callerNumber { primaryPhoneNumber }
|
||||
startedAt endedAt durationSec
|
||||
disposition leadId
|
||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
||||
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
|
||||
Reference in New Issue
Block a user