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>
This commit is contained in:
2026-04-10 16:03:48 +05:30
parent 9665500b63
commit 96d0c32000
4 changed files with 40 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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