mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
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:
191
src/maint/maint.controller.ts
Normal file
191
src/maint/maint.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
20
src/maint/maint.guard.ts
Normal file
20
src/maint/maint.guard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class MaintGuard implements CanActivate {
|
||||
private readonly otp: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.otp = process.env.MAINT_OTP ?? '400168';
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const provided = request.headers['x-maint-otp'] ?? request.body?.otp;
|
||||
if (!provided || provided !== this.otp) {
|
||||
throw new HttpException('Invalid maintenance OTP', 403);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
12
src/maint/maint.module.ts
Normal file
12
src/maint/maint.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||
import { MaintController } from './maint.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, OzonetelAgentModule, AuthModule, SupervisorModule],
|
||||
controllers: [MaintController],
|
||||
})
|
||||
export class MaintModule {}
|
||||
Reference in New Issue
Block a user