feat: SSE agent state, maint module, timestamp fix, missed call lead lookup

- SSE agent state stream: supervisor maintains state map from Ozonetel webhooks, streams via /api/supervisor/agent-state/stream
- Force-logout via SSE: distinct force-logout event type avoids conflict with normal login cycle
- Maint module (/api/maint): OTP-guarded endpoints for force-ready, unlock-agent, backfill-missed-calls, fix-timestamps
- Fix Ozonetel IST→UTC timestamp conversion: istToUtc() in webhook controller and missed-queue service
- Missed call lead lookup: ingestion queries leads by phone, stores leadId + leadName on Call entity
- Timestamp backfill endpoint: throttled at 700ms/mutation, idempotent (skips already-fixed records)
- Structured logging: full JSON payloads for agent/call webhooks, [DISPOSE] trace with agentId
- Fix dead code: agent-state endpoint auto-assign was after return statement
- Export SupervisorService for cross-module injection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 22:04:31 +05:30
parent d3331e56c0
commit eb4000961f
10 changed files with 388 additions and 62 deletions

View File

@@ -65,7 +65,7 @@ export class OzonetelAgentController {
throw new HttpException('state required', 400);
}
this.logger.log(`Agent state change: ${this.defaultAgentId}${body.state} (${body.pauseReason ?? ''})`);
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId}${body.state} (${body.pauseReason ?? 'none'})`);
try {
const result = await this.ozonetelAgent.changeAgentState({
@@ -73,47 +73,31 @@ export class OzonetelAgentController {
state: body.state,
pauseReason: body.pauseReason,
});
this.logger.log(`[AGENT-STATE] Ozonetel response: ${JSON.stringify(result)}`);
// Auto-assign missed call when agent goes Ready
if (body.state === 'Ready') {
try {
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
if (assigned) {
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
return { ...result, assignedCall: assigned };
}
} catch (err) {
this.logger.warn(`[AGENT-STATE] Auto-assignment on Ready failed: ${err}`);
}
}
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`[AGENT-STATE] FAILED: ${message} ${responseData}`);
return { status: 'error', message };
}
// Auto-assign missed call when agent goes Ready
if (body.state === 'Ready') {
try {
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
if (assigned) {
return { status: 'ok', message: `State changed to Ready. Assigned missed call ${assigned.id}`, assignedCall: assigned };
}
} catch (err) {
this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
}
}
}
@Post('agent-ready')
async agentReady() {
this.logger.log(`Force ready: logging out and back in agent ${this.defaultAgentId}`);
try {
await this.ozonetelAgent.logoutAgent({
agentId: this.defaultAgentId,
password: this.defaultAgentPassword,
});
const result = await this.ozonetelAgent.loginAgent({
agentId: this.defaultAgentId,
password: this.defaultAgentPassword,
phoneNumber: this.defaultSipId,
mode: 'blended',
});
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed';
this.logger.error(`Force ready failed: ${message}`);
throw new HttpException(message, error.response?.status ?? 502);
}
}
// force-ready moved to /api/maint/force-ready
@Post('dispose')
async dispose(
@@ -132,19 +116,21 @@ export class OzonetelAgentController {
throw new HttpException('ucid and disposition required', 400);
}
this.logger.log(`Dispose: ucid=${body.ucid} disposition=${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'}`);
try {
const result = await this.ozonetelAgent.setDisposition({
agentId: this.defaultAgentId,
ucid: body.ucid,
disposition: ozonetelDisposition,
});
this.logger.log(`[DISPOSE] Ozonetel response: ${JSON.stringify(result)}`);
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
this.logger.error(`Dispose failed: ${message}`);
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
}
// Handle missed call callback status update
@@ -188,7 +174,7 @@ export class OzonetelAgentController {
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
this.logger.log(`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`);
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
try {
const result = await this.ozonetelAgent.manualDial({