mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
- Team module: POST /api/team/members (in-place employee creation with temp password + Redis cache), PUT /api/team/members/:id, GET temp password endpoint. Uses signUpInWorkspace — no email invites. - Dockerfile: rewritten as multi-stage build (builder + runtime) so native modules compile for target arch. Fixes darwin→linux crash. - .dockerignore: exclude dist, node_modules, .env, .git, data/ - package-lock.json: regenerated against public npmjs.org (was pointing at localhost:4873 Verdaccio — broke docker builds) - Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors helper for visit-slot-aware queries across 6 consumers - AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped setup-state with workspace ID isolation, AI prompt defaults overhaul - Agent config: camelCase field fix for SDK-synced workspaces - Session service: workspace-scoped Redis key prefixing for setup state - Recordings/supervisor/widget services: updated to use doctor-utils shared fragments instead of inline visitingHours queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
140 lines
5.7 KiB
TypeScript
140 lines
5.7 KiB
TypeScript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { Subject } from 'rxjs';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
|
|
|
type ActiveCall = {
|
|
ucid: string;
|
|
agentId: string;
|
|
callerNumber: string;
|
|
callType: string;
|
|
startTime: string;
|
|
status: 'active' | 'on-hold';
|
|
};
|
|
|
|
export type AgentOzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
|
|
|
type AgentStateEntry = {
|
|
state: AgentOzonetelState;
|
|
timestamp: string;
|
|
};
|
|
|
|
@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>();
|
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
|
|
|
constructor(
|
|
private platform: PlatformGraphqlService,
|
|
private ozonetel: OzonetelAgentService,
|
|
private config: ConfigService,
|
|
) {}
|
|
|
|
async onModuleInit() {
|
|
this.logger.log('Supervisor service initialized');
|
|
}
|
|
|
|
handleCallEvent(event: any) {
|
|
const action = event.action;
|
|
const ucid = event.ucid ?? event.monitorUCID;
|
|
const agentId = event.agent_id ?? event.agentID;
|
|
const callerNumber = event.caller_id ?? event.callerID;
|
|
const callType = event.call_type ?? event.Type;
|
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
|
|
|
if (!ucid) return;
|
|
|
|
if (action === 'Answered' || action === 'Calling') {
|
|
this.activeCalls.set(ucid, {
|
|
ucid, agentId, callerNumber,
|
|
callType, startTime: eventTime, status: 'active',
|
|
});
|
|
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
|
} else if (action === 'Disconnect') {
|
|
this.activeCalls.delete(ucid);
|
|
this.logger.log(`Call ended: ${ucid}`);
|
|
}
|
|
}
|
|
|
|
handleAgentEvent(event: any) {
|
|
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
|
const action = event.action ?? 'unknown';
|
|
const eventData = event.eventData ?? '';
|
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
|
this.logger.log(`[AGENT-STATE] ${agentId} → ${action}${eventData ? ` (${eventData})` : ''} at ${eventTime}`);
|
|
|
|
const mapped = this.mapOzonetelAction(action, eventData);
|
|
if (mapped) {
|
|
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
|
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
|
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
|
}
|
|
}
|
|
|
|
private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null {
|
|
switch (action) {
|
|
case 'release': return 'ready';
|
|
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
|
case 'calling': return 'calling';
|
|
case 'incall': return 'in-call';
|
|
case 'ACW': return 'acw';
|
|
case 'logout': return 'offline';
|
|
case 'AUX':
|
|
// "changeMode" is the brief AUX during login — not a real pause
|
|
if (eventData === 'changeMode') return null;
|
|
if (eventData?.toLowerCase().includes('training')) return 'training';
|
|
return 'break';
|
|
case 'login': return null; // wait for release
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
getAgentState(agentId: string): AgentStateEntry | null {
|
|
return this.agentStates.get(agentId) ?? null;
|
|
}
|
|
|
|
emitForceLogout(agentId: string) {
|
|
this.logger.log(`[AGENT-STATE] Emitting force-logout for ${agentId}`);
|
|
this.agentStates.set(agentId, { state: 'offline', timestamp: new Date().toISOString() });
|
|
// Use a special state so frontend can distinguish admin force-logout from normal Ozonetel logout
|
|
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
|
}
|
|
|
|
getActiveCalls(): ActiveCall[] {
|
|
return Array.from(this.activeCalls.values());
|
|
}
|
|
|
|
async getTeamPerformance(date: string): Promise<any> {
|
|
// Get all agents from platform. Field names are label-derived
|
|
// camelCase on the current platform schema — see
|
|
// agent-config.service.ts for the canonical explanation of the
|
|
// legacy lowercase names that used to exist on staging.
|
|
const agentData = await this.platform.query<any>(
|
|
`{ agents(first: 20) { edges { node {
|
|
id name ozonetelAgentId npsScore
|
|
maxIdleMinutes minNpsThreshold minConversion
|
|
} } } }`,
|
|
);
|
|
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
|
|
|
// Fetch Ozonetel time summary per agent
|
|
const summaries = await Promise.all(
|
|
agents.map(async (agent: any) => {
|
|
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null };
|
|
try {
|
|
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
|
return { ...agent, timeBreakdown: summary };
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
|
|
return { ...agent, timeBreakdown: null };
|
|
}
|
|
}),
|
|
);
|
|
|
|
return { date, agents: summaries };
|
|
}
|
|
}
|