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

@@ -0,0 +1,191 @@
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MaintGuard } from './maint.guard';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SessionService } from '../auth/session.service';
import { SupervisorService } from '../supervisor/supervisor.service';
@Controller('api/maint')
@UseGuards(MaintGuard)
export class MaintController {
private readonly logger = new Logger(MaintController.name);
constructor(
private readonly config: ConfigService,
private readonly ozonetel: OzonetelAgentService,
private readonly platform: PlatformGraphqlService,
private readonly session: SessionService,
private readonly supervisor: SupervisorService,
) {}
@Post('force-ready')
async forceReady() {
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
const sipId = this.config.get<string>('OZONETEL_SIP_ID') ?? '521814';
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
try {
await this.ozonetel.logoutAgent({ agentId, password });
const result = await this.ozonetel.loginAgent({
agentId,
password,
phoneNumber: sipId,
mode: 'blended',
});
this.logger.log(`[MAINT] Force ready complete: ${JSON.stringify(result)}`);
return { status: 'ok', message: `Agent ${agentId} force-readied`, result };
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed';
this.logger.error(`[MAINT] Force ready failed: ${message}`);
return { status: 'error', message };
}
}
@Post('unlock-agent')
async unlockAgent() {
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
try {
const existing = await this.session.getSession(agentId);
if (!existing) {
return { status: 'ok', message: `No active session for ${agentId}` };
}
await this.session.unlockSession(agentId);
// Push force-logout via SSE to all connected browsers for this agent
this.supervisor.emitForceLogout(agentId);
this.logger.log(`[MAINT] Session unlocked + force-logout pushed for ${agentId} (was held by IP ${existing.ip} since ${existing.lockedAt})`);
return { status: 'ok', message: `Session unlocked and force-logout sent for ${agentId}`, previousSession: existing };
} catch (error: any) {
this.logger.error(`[MAINT] Unlock failed: ${error.message}`);
return { status: 'error', message: error.message };
}
}
@Post('backfill-missed-calls')
async backfillMissedCalls() {
this.logger.log('[MAINT] Backfill missed call lead names — starting');
// Fetch all missed calls without a leadId
const result = await this.platform.query<any>(
`{ calls(first: 200, filter: {
callStatus: { eq: MISSED },
leadId: { is: NULL }
}) { edges { node { id callerNumber { primaryPhoneNumber } } } } }`,
);
const calls = result?.calls?.edges?.map((e: any) => e.node) ?? [];
if (calls.length === 0) {
this.logger.log('[MAINT] No missed calls without leadId found');
return { status: 'ok', total: 0, patched: 0 };
}
this.logger.log(`[MAINT] Found ${calls.length} missed calls without leadId`);
let patched = 0;
let skipped = 0;
for (const call of calls) {
const phone = call.callerNumber?.primaryPhoneNumber;
if (!phone) { skipped++; continue; }
const phoneDigits = phone.replace(/^\+91/, '');
try {
const leadResult = await this.platform.query<any>(
`{ leads(first: 1, filter: {
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
}) { edges { node { id contactName { firstName lastName } } } } }`,
);
const lead = leadResult?.leads?.edges?.[0]?.node;
if (!lead) { skipped++; continue; }
const fn = lead.contactName?.firstName ?? '';
const ln = lead.contactName?.lastName ?? '';
const leadName = `${fn} ${ln}`.trim();
await this.platform.query<any>(
`mutation { updateCall(id: "${call.id}", data: {
leadId: "${lead.id}"${leadName ? `, leadName: "${leadName}"` : ''}
}) { id } }`,
);
patched++;
this.logger.log(`[MAINT] Patched ${phone}${leadName} (${lead.id})`);
} catch (err) {
this.logger.warn(`[MAINT] Failed to patch ${call.id}: ${err}`);
skipped++;
}
}
this.logger.log(`[MAINT] Backfill complete: ${patched} patched, ${skipped} skipped out of ${calls.length}`);
return { status: 'ok', total: calls.length, patched, skipped };
}
@Post('fix-timestamps')
async fixTimestamps() {
this.logger.log('[MAINT] Fix call timestamps — subtracting 5:30 IST offset from existing records');
const result = await this.platform.query<any>(
`{ calls(first: 200) { edges { node { id startedAt endedAt createdAt } } } }`,
);
const calls = result?.calls?.edges?.map((e: any) => e.node) ?? [];
if (calls.length === 0) {
return { status: 'ok', total: 0, fixed: 0 };
}
this.logger.log(`[MAINT] Found ${calls.length} call records to check`);
let fixed = 0;
let skipped = 0;
for (const call of calls) {
if (!call.startedAt) { skipped++; continue; }
// Skip records that don't need fixing: if startedAt is BEFORE createdAt,
// it was already corrected (or is naturally correct)
const started = new Date(call.startedAt).getTime();
const created = new Date(call.createdAt).getTime();
if (started <= created) {
skipped++;
continue;
}
try {
const updates: string[] = [];
const startDate = new Date(call.startedAt);
startDate.setMinutes(startDate.getMinutes() - 330);
updates.push(`startedAt: "${startDate.toISOString()}"`);
if (call.endedAt) {
const endDate = new Date(call.endedAt);
endDate.setMinutes(endDate.getMinutes() - 330);
updates.push(`endedAt: "${endDate.toISOString()}"`);
}
await this.platform.query<any>(
`mutation { updateCall(id: "${call.id}", data: { ${updates.join(', ')} }) { id } }`,
);
fixed++;
// Throttle: 700ms between mutations to stay under 100/min rate limit
await new Promise(resolve => setTimeout(resolve, 700));
} catch (err) {
this.logger.warn(`[MAINT] Failed to fix ${call.id}: ${err}`);
skipped++;
}
}
this.logger.log(`[MAINT] Timestamp fix complete: ${fixed} fixed, ${skipped} skipped out of ${calls.length}`);
return { status: 'ok', total: calls.length, fixed, skipped };
}
}