mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
Compare commits
15 Commits
feature/om
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f5bd7d61a | |||
| f1313f0e2f | |||
| 44f1ec36e1 | |||
| 4bd08a9b02 | |||
| 0248c4cad1 | |||
| be505b8d1f | |||
| dbefa9675a | |||
| 9dc02e107a | |||
| c807cf737f | |||
| 96d0c32000 | |||
| 9665500b63 | |||
| 9f5935e417 | |||
| 898ff65951 | |||
| 7717536622 | |||
| 33dc8b5669 |
24
.woodpecker.yml
Normal file
24
.woodpecker.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Woodpecker CI pipeline for Helix Engage Server (sidecar)
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: [push, manual]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
unit-tests:
|
||||||
|
image: node:20
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- npm test -- --ci --forceExit
|
||||||
|
|
||||||
|
notify-teams:
|
||||||
|
image: curlimages/curl
|
||||||
|
environment:
|
||||||
|
TEAMS_WEBHOOK:
|
||||||
|
from_secret: teams_webhook
|
||||||
|
commands:
|
||||||
|
- >
|
||||||
|
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":"Helix Engage Server — Build #'"$CI_PIPELINE_NUMBER"'"},{"type":"TextBlock","text":"Branch: '"$CI_COMMIT_BRANCH"'","wrap":true},{"type":"TextBlock","text":"'"$(echo $CI_COMMIT_MESSAGE | head -c 80)"'","wrap":true}],"actions":[{"type":"Action.OpenUrl","title":"View Pipeline","url":"https://operations.healix360.net/repos/2/pipeline/'"$CI_PIPELINE_NUMBER"'"}]}}]}'
|
||||||
|
when:
|
||||||
|
- status: [success, failure]
|
||||||
@@ -271,7 +271,7 @@ export class AiChatController {
|
|||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
const data = await platformService.queryWithAuth<any>(
|
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,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const breached = data.calls.edges
|
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 { ConfigThemeModule } from './config/config-theme.module';
|
||||||
import { WidgetModule } from './widget/widget.module';
|
import { WidgetModule } from './widget/widget.module';
|
||||||
import { TeamModule } from './team/team.module';
|
import { TeamModule } from './team/team.module';
|
||||||
|
import { MasterdataModule } from './masterdata/masterdata.module';
|
||||||
|
import { TelephonyRegistrationService } from './telephony-registration.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -48,6 +50,8 @@ import { TeamModule } from './team/team.module';
|
|||||||
ConfigThemeModule,
|
ConfigThemeModule,
|
||||||
WidgetModule,
|
WidgetModule,
|
||||||
TeamModule,
|
TeamModule,
|
||||||
|
MasterdataModule,
|
||||||
],
|
],
|
||||||
|
providers: [TelephonyRegistrationService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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
|
// 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.
|
// the first-boot seeder. Keep in sync with the migration target sites.
|
||||||
export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [
|
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_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] },
|
||||||
{ env: 'OZONETEL_DID', path: ['ozonetel', 'did'] },
|
{ env: 'OZONETEL_DID', path: ['ozonetel', 'did'] },
|
||||||
{ env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] },
|
{ 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 { MaintGuard } from './maint.guard';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
@@ -22,11 +22,14 @@ export class MaintController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('force-ready')
|
@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 oz = this.telephony.getConfig().ozonetel;
|
||||||
const agentId = oz.agentId || 'agent3';
|
const password = oz.agentPassword;
|
||||||
const password = oz.agentPassword || 'Test123$';
|
if (!password) throw new HttpException('agent password not configured', 400);
|
||||||
const sipId = oz.sipId || '521814';
|
const sipId = oz.sipId;
|
||||||
|
if (!sipId) throw new HttpException('SIP ID not configured', 400);
|
||||||
|
|
||||||
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
||||||
|
|
||||||
@@ -48,8 +51,9 @@ export class MaintController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('unlock-agent')
|
@Post('unlock-agent')
|
||||||
async unlockAgent() {
|
async unlockAgent(@Body() body: { agentId: string }) {
|
||||||
const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||||
|
const agentId = body.agentId;
|
||||||
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||||
|
|
||||||
try {
|
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 { EventBusService } from '../events/event-bus.service';
|
||||||
import { Topics } from '../events/event-types';
|
import { Topics } from '../events/event-types';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
|
||||||
@Controller('api/ozonetel')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
@@ -16,17 +17,12 @@ export class OzonetelAgentController {
|
|||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly eventBus: EventBusService,
|
private readonly eventBus: EventBusService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Read-through accessors so admin updates take effect immediately.
|
private requireAgentId(agentId: string | undefined | null): string {
|
||||||
private get defaultAgentId(): string {
|
if (!agentId) throw new HttpException('agentId required', 400);
|
||||||
return this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
return agentId;
|
||||||
}
|
|
||||||
private get defaultAgentPassword(): string {
|
|
||||||
return this.telephony.getConfig().ozonetel.agentPassword;
|
|
||||||
}
|
|
||||||
private get defaultSipId(): string {
|
|
||||||
return this.telephony.getConfig().ozonetel.sipId || '521814';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-login')
|
@Post('agent-login')
|
||||||
@@ -65,17 +61,18 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
@Post('agent-state')
|
@Post('agent-state')
|
||||||
async agentState(
|
async agentState(
|
||||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
@Body() body: { agentId: string; state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||||
) {
|
) {
|
||||||
if (!body.state) {
|
if (!body.state) {
|
||||||
throw new HttpException('state required', 400);
|
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 {
|
try {
|
||||||
const result = await this.ozonetelAgent.changeAgentState({
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
state: body.state,
|
state: body.state,
|
||||||
pauseReason: body.pauseReason,
|
pauseReason: body.pauseReason,
|
||||||
});
|
});
|
||||||
@@ -84,7 +81,7 @@ export class OzonetelAgentController {
|
|||||||
// Auto-assign missed call when agent goes Ready
|
// Auto-assign missed call when agent goes Ready
|
||||||
if (body.state === 'Ready') {
|
if (body.state === 'Ready') {
|
||||||
try {
|
try {
|
||||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
const assigned = await this.missedQueue.assignNext(agentId);
|
||||||
if (assigned) {
|
if (assigned) {
|
||||||
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||||
return { ...result, assignedCall: assigned };
|
return { ...result, assignedCall: assigned };
|
||||||
@@ -110,6 +107,7 @@ export class OzonetelAgentController {
|
|||||||
@Body() body: {
|
@Body() body: {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
disposition: string;
|
disposition: string;
|
||||||
|
agentId: string;
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
@@ -122,13 +120,17 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('ucid and disposition required', 400);
|
throw new HttpException('ucid and disposition required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentId = this.requireAgentId(body.agentId);
|
||||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
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 {
|
try {
|
||||||
const result = await this.ozonetelAgent.setDisposition({
|
const result = await this.ozonetelAgent.setDisposition({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
disposition: ozonetelDisposition,
|
disposition: ozonetelDisposition,
|
||||||
});
|
});
|
||||||
@@ -139,6 +141,37 @@ export class OzonetelAgentController {
|
|||||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
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
|
// Handle missed call callback status update
|
||||||
if (body.missedCallId) {
|
if (body.missedCallId) {
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
@@ -152,7 +185,7 @@ export class OzonetelAgentController {
|
|||||||
if (newStatus) {
|
if (newStatus) {
|
||||||
try {
|
try {
|
||||||
await this.platform.query<any>(
|
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) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to update missed call status: ${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
|
// Auto-assign next missed call to this agent
|
||||||
try {
|
try {
|
||||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
await this.missedQueue.assignNext(agentId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||||
}
|
}
|
||||||
@@ -171,7 +204,7 @@ export class OzonetelAgentController {
|
|||||||
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||||
callId: null,
|
callId: null,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
callerPhone: body.callerPhone ?? '',
|
callerPhone: body.callerPhone ?? '',
|
||||||
direction: body.direction ?? 'INBOUND',
|
direction: body.direction ?? 'INBOUND',
|
||||||
durationSec: body.durationSec ?? 0,
|
durationSec: body.durationSec ?? 0,
|
||||||
@@ -186,19 +219,27 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
@Post('dial')
|
@Post('dial')
|
||||||
async dial(
|
async dial(
|
||||||
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
@Body() body: { phoneNumber: string; agentId: string; campaignName?: string; leadId?: string },
|
||||||
) {
|
) {
|
||||||
if (!body.phoneNumber) {
|
if (!body.phoneNumber) {
|
||||||
throw new HttpException('phoneNumber required', 400);
|
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 {
|
try {
|
||||||
const result = await this.ozonetelAgent.manualDial({
|
const result = await this.ozonetelAgent.manualDial({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
campaignName,
|
campaignName,
|
||||||
customerNumber: body.phoneNumber,
|
customerNumber: body.phoneNumber,
|
||||||
});
|
});
|
||||||
@@ -276,23 +317,27 @@ export class OzonetelAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@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];
|
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([
|
const [cdr, summary, aht] = await Promise.all([
|
||||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
this.ozonetelAgent.getAHT(agent),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalCalls = cdr.length;
|
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
||||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
const agentCdr = cdr.filter((c: any) => c.AgentID === agent || c.AgentName === agent);
|
||||||
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;
|
|
||||||
|
|
||||||
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')
|
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||||
.map((c: any) => {
|
.map((c: any) => {
|
||||||
const parts = c.TalkTime.split(':').map(Number);
|
const parts = c.TalkTime.split(':').map(Number);
|
||||||
@@ -303,12 +348,12 @@ export class OzonetelAgentController {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const dispositions: Record<string, number> = {};
|
const dispositions: Record<string, number> = {};
|
||||||
for (const c of cdr) {
|
for (const c of agentCdr) {
|
||||||
const d = (c as any).Disposition || 'No Disposition';
|
const d = (c as any).Disposition || 'No Disposition';
|
||||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appointmentsBooked = cdr.filter((c: any) =>
|
const appointmentsBooked = agentCdr.filter((c: any) =>
|
||||||
c.Disposition?.toLowerCase().includes('appointment'),
|
c.Disposition?.toLowerCase().includes('appointment'),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { OzonetelAgentService } from './ozonetel-agent.service';
|
|||||||
import { KookooIvrController } from './kookoo-ivr.controller';
|
import { KookooIvrController } from './kookoo-ivr.controller';
|
||||||
import { WorklistModule } from '../worklist/worklist.module';
|
import { WorklistModule } from '../worklist/worklist.module';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)],
|
||||||
controllers: [OzonetelAgentController, KookooIvrController],
|
controllers: [OzonetelAgentController, KookooIvrController],
|
||||||
providers: [OzonetelAgentService],
|
providers: [OzonetelAgentService],
|
||||||
exports: [OzonetelAgentService],
|
exports: [OzonetelAgentService],
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export class CallFactsProvider implements FactProvider {
|
|||||||
'call.status': call.callStatus ?? null,
|
'call.status': call.callStatus ?? null,
|
||||||
'call.disposition': call.disposition ?? null,
|
'call.disposition': call.disposition ?? null,
|
||||||
'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0,
|
'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.slaElapsedPercent': slaElapsedPercent,
|
||||||
'call.slaBreached': slaElapsedPercent > 100,
|
'call.slaBreached': slaElapsedPercent > 100,
|
||||||
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
|
'call.missedCount': call.missedCallCount ?? call.missedCount ?? 0,
|
||||||
'call.taskType': taskType,
|
'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 { PlatformModule } from '../platform/platform.module';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { SupervisorController } from './supervisor.controller';
|
import { SupervisorController } from './supervisor.controller';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, OzonetelAgentModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||||
controllers: [SupervisorController],
|
controllers: [SupervisorController],
|
||||||
providers: [SupervisorService],
|
providers: [SupervisorService],
|
||||||
exports: [SupervisorService],
|
exports: [SupervisorService],
|
||||||
|
|||||||
@@ -20,11 +20,20 @@ type AgentStateEntry = {
|
|||||||
timestamp: string;
|
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()
|
@Injectable()
|
||||||
export class SupervisorService implements OnModuleInit {
|
export class SupervisorService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SupervisorService.name);
|
private readonly logger = new Logger(SupervisorService.name);
|
||||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
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 }>();
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -37,6 +46,17 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
this.logger.log('Supervisor service initialized');
|
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) {
|
handleCallEvent(event: any) {
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
const ucid = event.ucid ?? event.monitorUCID;
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
@@ -48,6 +68,12 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
if (!ucid) return;
|
if (!ucid) return;
|
||||||
|
|
||||||
if (action === 'Answered' || action === 'Calling') {
|
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, {
|
this.activeCalls.set(ucid, {
|
||||||
ucid, agentId, callerNumber,
|
ucid, agentId, callerNumber,
|
||||||
callType, startTime: eventTime, status: 'active',
|
callType, startTime: eventTime, status: 'active',
|
||||||
@@ -71,6 +97,46 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
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) ?? [];
|
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(
|
const summaries = await Promise.all(
|
||||||
agents.map(async (agent: any) => {
|
agents.map(async (agent: any) => {
|
||||||
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null };
|
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null, calls: null };
|
||||||
try {
|
try {
|
||||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
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) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${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 };
|
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
|
// Determine call status for our platform
|
||||||
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
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
|
// Use API key auth for server-to-server writes
|
||||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
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
|
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||||
if (data.callStatus === 'MISSED') {
|
if (data.callStatus === 'MISSED') {
|
||||||
callData.callbackstatus = 'PENDING_CALLBACK';
|
callData.callbackStatus = 'PENDING_CALLBACK';
|
||||||
callData.missedcallcount = 1;
|
callData.missedCallCount = 1;
|
||||||
}
|
}
|
||||||
if (data.recordingUrl) {
|
if (data.recordingUrl) {
|
||||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.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
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
export function istToUtc(istDateStr: string | null): string | null {
|
export function istToUtc(istDateStr: string | null): string | null {
|
||||||
@@ -33,10 +34,16 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ozonetel: OzonetelAgentService,
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
|
private readonly telephony: TelephonyConfigService,
|
||||||
) {
|
) {
|
||||||
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
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() {
|
onModuleInit() {
|
||||||
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
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);
|
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 };
|
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;
|
const ucid = call.monitorUCID;
|
||||||
if (!ucid || this.processedUcids.has(ucid)) continue;
|
if (!ucid || this.processedUcids.has(ucid)) continue;
|
||||||
this.processedUcids.add(ucid);
|
this.processedUcids.add(ucid);
|
||||||
@@ -97,19 +114,19 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
|
|
||||||
const existing = await this.platform.query<any>(
|
const existing = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
||||||
}) { edges { node { id missedcallcount } } } }`,
|
}) { edges { node { id missedCallCount } } } }`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingNode = existing?.calls?.edges?.[0]?.node;
|
const existingNode = existing?.calls?.edges?.[0]?.node;
|
||||||
|
|
||||||
if (existingNode) {
|
if (existingNode) {
|
||||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
const newCount = (existingNode.missedCallCount || 1) + 1;
|
||||||
const updateParts = [
|
const updateParts = [
|
||||||
`missedcallcount: ${newCount}`,
|
`missedCallCount: ${newCount}`,
|
||||||
`startedAt: "${callTime}"`,
|
`startedAt: "${callTime}"`,
|
||||||
`callsourcenumber: "${did}"`,
|
`callSourceNumber: "${did}"`,
|
||||||
];
|
];
|
||||||
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
||||||
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
||||||
@@ -123,9 +140,9 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
`callStatus: MISSED`,
|
`callStatus: MISSED`,
|
||||||
`direction: INBOUND`,
|
`direction: INBOUND`,
|
||||||
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
||||||
`callsourcenumber: "${did}"`,
|
`callSourceNumber: "${did}"`,
|
||||||
`callbackstatus: PENDING_CALLBACK`,
|
`callbackStatus: PENDING_CALLBACK`,
|
||||||
`missedcallcount: 1`,
|
`missedCallCount: 1`,
|
||||||
`startedAt: "${callTime}"`,
|
`startedAt: "${callTime}"`,
|
||||||
];
|
];
|
||||||
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
||||||
@@ -160,12 +177,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
||||||
let result = await this.platform.query<any>(
|
let result = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
agentName: { eq: "" }
|
agentName: { eq: "" }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
edges { node {
|
edges { node {
|
||||||
id callerNumber { primaryPhoneNumber }
|
id callerNumber { primaryPhoneNumber }
|
||||||
startedAt callsourcenumber missedcallcount
|
startedAt callSourceNumber missedCallCount
|
||||||
} }
|
} }
|
||||||
} }`,
|
} }`,
|
||||||
);
|
);
|
||||||
@@ -176,12 +193,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
if (!call) {
|
if (!call) {
|
||||||
result = await this.platform.query<any>(
|
result = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
agentName: { is: NULL }
|
agentName: { is: NULL }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
edges { node {
|
edges { node {
|
||||||
id callerNumber { primaryPhoneNumber }
|
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(', ')}`);
|
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') {
|
if (status === 'CALLBACK_ATTEMPTED') {
|
||||||
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
dataParts.push(`callbackAttemptedAt: "${new Date().toISOString()}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.platform.queryWithAuth<any>(
|
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,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
@@ -230,12 +247,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
const fields = `id name createdAt direction callStatus agentName
|
const fields = `id name createdAt direction callStatus agentName
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
startedAt endedAt durationSec disposition leadId
|
startedAt endedAt durationSec disposition leadId
|
||||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt`;
|
||||||
|
|
||||||
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
||||||
agentName: { eq: "${agentName}" },
|
agentName: { eq: "${agentName}" },
|
||||||
callStatus: { eq: MISSED },
|
callStatus: { eq: MISSED },
|
||||||
callbackstatus: { eq: ${status} }
|
callbackStatus: { eq: ${status} }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { MissedQueueService, istToUtc, normalizePhone } from './missed-queue.service';
|
import { MissedQueueService, istToUtc, normalizePhone } from './missed-queue.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { ABANDON_CALL_RECORD } from '../__fixtures__/ozonetel-payloads';
|
import { ABANDON_CALL_RECORD } from '../__fixtures__/ozonetel-payloads';
|
||||||
|
|
||||||
describe('MissedQueueService', () => {
|
describe('MissedQueueService', () => {
|
||||||
@@ -57,6 +58,16 @@ describe('MissedQueueService', () => {
|
|||||||
getAbandonCalls: jest.fn().mockResolvedValue([ABANDON_CALL_RECORD]),
|
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();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { WorklistController } from './worklist.controller';
|
import { WorklistController } from './worklist.controller';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
import { MissedQueueService } from './missed-queue.service';
|
import { MissedQueueService } from './missed-queue.service';
|
||||||
@@ -12,7 +13,7 @@ import { KookooCallbackController } from './kookoo-callback.controller';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
||||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||||
providers: [WorklistService, MissedQueueService],
|
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
||||||
exports: [MissedQueueService],
|
exports: [MissedQueueService],
|
||||||
})
|
})
|
||||||
export class WorklistModule {}
|
export class WorklistModule {}
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ export class WorklistService {
|
|||||||
try {
|
try {
|
||||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
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
|
id name createdAt
|
||||||
direction callStatus agentName
|
direction callStatus agentName
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
disposition leadId
|
disposition leadId
|
||||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
|
|||||||
Reference in New Issue
Block a user