15 Commits

Author SHA1 Message Date
0f5bd7d61a ci: fix Teams notification — use Adaptive Card with curl
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 15:37:20 +05:30
f1313f0e2f ci: use Teams notification plugin
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 15:34:30 +05:30
44f1ec36e1 ci: add Woodpecker pipeline — unit tests + Teams notification
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 15:29:49 +05:30
4bd08a9b02 fix: remove defaultAgentId fallback — require agentId from caller
agent-state, dispose, dial, performance, force-ready, unlock-agent
all required agentId from the request body now. No silent fallback
to OZONETEL_AGENT_ID env var which caused cross-tenant operations
in multi-agent setups (Ramaiah operations hitting Global's agent).

OZONETEL_AGENT_ID removed from telephony env seed list. Hardcoded
fallbacks (agent3, Test123$, 521814) deleted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:10:31 +05:30
0248c4cad1 fix: #536 #538 performance metrics — filter CDR by agentId, add team call counts
#536: Performance endpoint now accepts agentId query param and filters
CDR to that agent only. Previously returned all agents' calls as one
agent's total. Fixed 'Unanswered' → 'NotAnswered' status filter.

#538: Team performance now includes per-agent call metrics (total,
inbound, outbound, answered, missed) from CDR data + teamTotals
aggregate. Previously only returned Ozonetel time breakdown without
any call counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:33:59 +05:30
be505b8d1f fix: #540 ignore call events for offline agents in live monitor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:27:09 +05:30
dbefa9675a feat: master data endpoint — cached departments, doctors, clinics
Redis-cached (5min TTL) lookups via /api/masterdata/departments,
/api/masterdata/doctors, /api/masterdata/clinics. Warms cache on
startup. Frontend dropdowns use these instead of hardcoded lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:31:01 +05:30
9dc02e107a fix: E.164 phone format for outbound call records (+91 prefix)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:27:50 +05:30
c807cf737f fix: outbound call records via dispose + campaign-filtered polling
- Dispose endpoint creates Call entity for outbound calls (direction=OUTBOUND).
  The webhook now skips outbound, so dispose is the only path for outbound records.
- MissedQueueService filters abandonCalls by own campaign (read from TelephonyConfigService).
  Prevents cross-tenant ingestion from shared Ozonetel account.
- WorklistModule provides TelephonyConfigService directly (avoids circular dep with ConfigThemeModule).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:20:45 +05:30
96d0c32000 fix: skip outbound calls in webhook + filter abandon polls by campaign
Webhook controller now skips outbound calls (type=Manual/OutBound).
An unanswered outbound dial is NOT a missed inbound call — it was
being incorrectly created as MISSED with PENDING_CALLBACK status.

MissedQueueService now filters the Ozonetel abandonCalls API response
by campaign name (read from TelephonyConfigService). Prevents
cross-tenant ingestion when multiple sidecars share the same
Ozonetel account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:09:17 +05:30
9665500b63 fix: dispose uses per-agent ID + campaign fallback operator precedence
Dispose endpoint now accepts agentId from body (same pattern as dial
fix). Fixes "Invalid Agent ID" when disposing as non-default agent.
Also fixed JS operator precedence bug in campaign name fallback
that produced "Inbound_" instead of "Inbound_918041763400".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:49:10 +05:30
9f5935e417 feat: telephony dispatcher registration — sidecar self-registers on boot
Adds TelephonyRegistrationService that:
1. On startup: queries platform for agent list, registers with the
   telephony dispatcher at TELEPHONY_DISPATCHER_URL
2. Every 30s: sends heartbeat to keep registration alive (90s TTL)
3. On shutdown: deregisters (best-effort, TTL cleans up anyway)
4. On heartbeat failure: auto re-registers

Env vars:
  TELEPHONY_DISPATCHER_URL — where to register (outbound to dispatcher)
  TELEPHONY_CALLBACK_URL — where events come back (inbound to sidecar)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:08:30 +05:30
898ff65951 fix: camelCase field names + dial uses per-agent config
Defect 5: Worklist, missed-call-webhook, missed-queue, ai-chat, and
rules-engine all used legacy lowercase field names (callbackstatus,
callsourcenumber, missedcallcount, callbackattemptedat) from the old
VPS schema. Fixed to camelCase (callbackStatus, callSourceNumber,
missedCallCount, callbackAttemptedAt) matching the current SDK sync.

Defect 6: Dial endpoint used global defaults (OZONETEL_AGENT_ID env
var) instead of the logged-in agent's config. Now accepts agentId
and campaignName from the frontend request body. Falls back to
telephony config → DID-derived campaign name → explicit error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:29:19 +05:30
7717536622 fix: server-side ACW auto-dispose (Layer 3) — 30s timeout safety net
When Ozonetel sends an ACW event, starts a 30-second timer. If no
/api/ozonetel/dispose call arrives within that window (frontend
crashed, tab closed, page refreshed), auto-disposes with "General
Enquiry" + autoRelease:true. Agent exits ACW automatically.

Timer is cancelled when:
  - Frontend submits disposition normally (cancelAcwTimer in controller)
  - Agent transitions to Ready or Offline
  - Agent logs out

Wiring: OzonetelAgentModule now imports SupervisorModule (forwardRef
for circular dep), controller injects SupervisorService to cancel
the timer on successful dispose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:29:41 +05:30
33dc8b5669 merge: feature/omnichannel-widget → master
26 commits bringing the full omnichannel call-center stack:

Core features:
- Team module (in-place employee creation, temp passwords, role assignment)
- Multi-stage Dockerfile (fixes cross-arch native module crashes)
- Doctor visit slot entity support (shared fragment + normalizer)
- AI config CRUD (admin-editable prompts, workspace-scoped setup state)
- Widget chat with generative UI, captcha gate, lead dedup
- Call assist, supervisor, recordings services updated for new schema
- Session service with workspace-scoped Redis key prefixing

Infrastructure:
- Dockerfile rewritten as multi-stage builder → runtime
- package-lock.json regenerated (Verdaccio → public npmjs.org)
- .dockerignore hardened

Tests (48 passing):
- Ozonetel agent service (auth, dial, dispose, state, token cache)
- Missed call webhook (parsing, IST→UTC, duration, CallerID)
- Missed queue (abandon polling, PENDING_CALLBACK, dedup)
- Caller resolution (4-path phone→lead+patient, caching)
- Team service (5-step creation, SIP linking, validation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:36:11 +05:30
19 changed files with 646 additions and 77 deletions

24
.woodpecker.yml Normal file
View 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]

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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'] },

View File

@@ -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 {

View 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 };
}
}

View 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 {}

View 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');
}
}

View File

@@ -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;

View File

@@ -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],

View File

@@ -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,
};
}

View File

@@ -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],

View File

@@ -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 };
}
}

View 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 [];
}
}
}

View File

@@ -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' };

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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 {}

View File

@@ -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,