mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
5 Commits
96977e84a1
...
6adb3985cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 6adb3985cb | |||
| 67c41f4783 | |||
| d459d6469a | |||
| 60d2329dd8 | |||
| f375e7736c |
@@ -55,6 +55,26 @@ export class SessionService {
|
||||
await this.redis.del(this.key(agentId));
|
||||
}
|
||||
|
||||
// Enumerate every active session lock so the maint UI can show which
|
||||
// agentIds are currently held (and by whom) vs free. Uses SCAN, not
|
||||
// KEYS, to avoid blocking Redis on workspaces with many keys.
|
||||
async listLockedSessions(): Promise<Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }>> {
|
||||
const out: Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }> = [];
|
||||
const stream = this.redis.scanStream({ match: 'agent:session:*', count: 100 });
|
||||
const keys: string[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on('data', (chunk: string[]) => keys.push(...chunk));
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
for (const key of keys) {
|
||||
const agentId = key.slice('agent:session:'.length);
|
||||
const session = await this.getSession(agentId);
|
||||
if (session) out.push({ agentId, ...session });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Generic cache operations for any module
|
||||
async getCache(key: string): Promise<string | null> {
|
||||
return this.redis.get(key);
|
||||
|
||||
@@ -53,4 +53,20 @@ export class SetupStateController {
|
||||
const updated = this.setupState.resetState();
|
||||
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||
}
|
||||
|
||||
// UI-level flags the frontend reads at app boot to tailor which admin
|
||||
// surfaces are available. Driven by sidecar env vars so each workspace
|
||||
// can be configured independently without touching the frontend build.
|
||||
//
|
||||
// setupManaged=true means "the product team handles setup for this
|
||||
// workspace" — hide the Settings nav, routes, and the resume-setup
|
||||
// banner. The wizard + setup-state APIs stay functional for ops use
|
||||
// (a support engineer can still PUT /steps/:step or hit the routes
|
||||
// directly); only the end-user admin UI is hidden.
|
||||
@Get('ui-flags')
|
||||
uiFlags() {
|
||||
return {
|
||||
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,59 @@ export class MaintController {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the current per-agent session state — which ozonetelAgentIds
|
||||
// are currently locked (held by a member IP) and which are free. Used
|
||||
// by the maint OTP modal to render a picker so a supervisor can unlock
|
||||
// the right agent without knowing the id off the top of their head.
|
||||
// Read-only; OTP-guarded like the rest of /api/maint.
|
||||
@Post('session-status')
|
||||
async sessionStatus() {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||
).catch(() => ({ agents: { edges: [] } }));
|
||||
|
||||
const allAgents = (data?.agents?.edges ?? []).map((e: any) => e.node).filter((a: any) => a.ozonetelAgentId);
|
||||
const sessions = await this.session.listLockedSessions();
|
||||
const sessionByAgent = new Map(sessions.map((s) => [s.agentId.toLowerCase(), s]));
|
||||
|
||||
const locked: Array<any> = [];
|
||||
const free: Array<any> = [];
|
||||
const seenAgentIds = new Set<string>();
|
||||
|
||||
for (const agent of allAgents) {
|
||||
const key = String(agent.ozonetelAgentId).toLowerCase();
|
||||
seenAgentIds.add(key);
|
||||
const session = sessionByAgent.get(key);
|
||||
const row = {
|
||||
agentId: agent.ozonetelAgentId,
|
||||
displayName: agent.name ?? agent.ozonetelDisplayName ?? agent.ozonetelAgentId,
|
||||
};
|
||||
if (session) {
|
||||
locked.push({ ...row, heldByIp: session.ip, lockedAt: session.lockedAt });
|
||||
} else {
|
||||
free.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Surface orphan locks (Redis holds a session for an ozonetelAgentId
|
||||
// with no matching Agent entity). Rare but possible after SDK renames
|
||||
// or workspace resets — without surfacing them, the operator can't
|
||||
// clear the stale lock via the UI.
|
||||
for (const session of sessions) {
|
||||
const key = session.agentId.toLowerCase();
|
||||
if (!seenAgentIds.has(key)) {
|
||||
locked.push({
|
||||
agentId: session.agentId,
|
||||
displayName: `${session.agentId} (orphan — no Agent record)`,
|
||||
heldByIp: session.ip,
|
||||
lockedAt: session.lockedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { locked, free };
|
||||
}
|
||||
|
||||
@Post('unlock-agent')
|
||||
async unlockAgent(@Body() body: { agentId: string }) {
|
||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||
|
||||
@@ -63,10 +63,18 @@ export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
||||
if (cdrRows.length === 0) continue;
|
||||
|
||||
// Build UCID → cdr-row map so we can O(1) join per Call.
|
||||
// Ozonetel emits two identifiers per call — `UCID` (caller-leg)
|
||||
// and `monitorUCID` (agent-leg). The webhook stores `monitorUCID`,
|
||||
// but the bulk CDR rows are keyed on caller-leg `UCID`. Index
|
||||
// both so the lookup at line ~79 finds the row regardless of
|
||||
// which side was persisted. Without this, transferred inbound
|
||||
// calls never get their agent relation enriched.
|
||||
const byUcid = new Map<string, any>();
|
||||
for (const row of cdrRows) {
|
||||
const ucid = String(row.UCID ?? '').trim();
|
||||
const monitorUcid = String(row.monitorUCID ?? '').trim();
|
||||
if (ucid) byUcid.set(ucid, row);
|
||||
if (monitorUcid && monitorUcid !== ucid) byUcid.set(monitorUcid, row);
|
||||
}
|
||||
if (byUcid.size === 0) continue;
|
||||
|
||||
@@ -80,9 +88,25 @@ export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!cdrRow) { skipped++; continue; }
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
const cdrAgentId = cdrRow.AgentID;
|
||||
if (cdrAgentId && !call.agentId) {
|
||||
const uuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId);
|
||||
if (!call.agentId) {
|
||||
// Primary resolution: use AgentID from CDR (unique lowercase id).
|
||||
const cdrAgentId = cdrRow.AgentID;
|
||||
let uuid = cdrAgentId
|
||||
? await this.agentLookup.resolveByOzonetelId(cdrAgentId)
|
||||
: null;
|
||||
// Fallback: CDR AgentName may be a chain ("A -> B") for
|
||||
// transferred calls. Pick the final handler (last segment)
|
||||
// and look it up by display name or ozonetelId. Matches
|
||||
// the write-time resolution in missed-call-webhook.
|
||||
if (!uuid && cdrRow.AgentName) {
|
||||
const segments = String(cdrRow.AgentName).split('->').map((s) => s.trim()).filter(Boolean);
|
||||
const finalHandler = segments[segments.length - 1];
|
||||
if (finalHandler) {
|
||||
uuid =
|
||||
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||
}
|
||||
}
|
||||
if (uuid) patch.agentId = uuid;
|
||||
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EventBusService } from '../events/event-bus.service';
|
||||
import { Topics } from '../events/event-types';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
import { AgentHistoryService } from '../supervisor/agent-history.service';
|
||||
|
||||
// Convert Ozonetel "HH:MM:SS" (or null/empty) to integer seconds.
|
||||
// Returns null when input is missing or all-zero.
|
||||
@@ -30,6 +31,7 @@ export class OzonetelAgentController {
|
||||
private readonly eventBus: EventBusService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
private readonly agentHistory: AgentHistoryService,
|
||||
) {}
|
||||
|
||||
private requireAgentId(agentId: string | undefined | null): string {
|
||||
@@ -405,12 +407,28 @@ export class OzonetelAgentController {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
||||
|
||||
const [cdr, summary, aht] = await Promise.all([
|
||||
// Trigger an on-demand rollup for the requested date so the
|
||||
// AgentSession row reflects the current open session (caps at now)
|
||||
// instead of waiting up to 15 min for the background tick. Fire-and-
|
||||
// forget with a short await so we don't block the whole response on
|
||||
// cache-refresh tail but still hand the read a fresh row when Redpanda
|
||||
// is quiet. Safe to error — AgentSession just stays stale.
|
||||
await this.agentHistory.rollupSessions(targetDate).catch(() => {});
|
||||
|
||||
const [cdr, summary, aht, agentSessionBreakdown] = await Promise.all([
|
||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||
this.ozonetelAgent.getAHT(agent),
|
||||
this.fetchAgentSessionTimeBreakdown(agent, targetDate),
|
||||
]);
|
||||
|
||||
// Prefer our AgentSession rollup when present — it correctly counts
|
||||
// the current OPEN session (caps at now), while Ozonetel's summaryReport
|
||||
// only tallies CLOSED login→logout pairs. Fall back to Ozonetel if
|
||||
// our rollup hasn't captured this agent yet (e.g., brand-new agent,
|
||||
// workspace without AgentEvent entity synced).
|
||||
const timeUtilization = agentSessionBreakdown ?? summary;
|
||||
|
||||
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
||||
// Use case-insensitive matching — Ozonetel field casing varies
|
||||
const agentLower = agent.toLowerCase();
|
||||
@@ -460,7 +478,7 @@ export class OzonetelAgentController {
|
||||
avgHandlingTime: aht,
|
||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||
appointmentsBooked,
|
||||
timeUtilization: summary,
|
||||
timeUtilization,
|
||||
dispositions,
|
||||
};
|
||||
}
|
||||
@@ -480,4 +498,52 @@ export class OzonetelAgentController {
|
||||
};
|
||||
return map[disposition] ?? 'General Enquiry';
|
||||
}
|
||||
|
||||
// Convert our AgentSession rollup (seconds per category) into the HH:MM:SS
|
||||
// shape the frontend expects — so My Performance gets LOGIN TIME with the
|
||||
// current open session included, not just closed sessions from Ozonetel.
|
||||
private async fetchAgentSessionTimeBreakdown(ozonetelAgentId: string, date: string): Promise<{
|
||||
totalLoginDuration: string;
|
||||
totalBusyTime: string;
|
||||
totalIdleTime: string;
|
||||
totalPauseTime: string;
|
||||
totalWrapupTime: string;
|
||||
totalDialTime: string;
|
||||
} | null> {
|
||||
try {
|
||||
const agentUuid = await this.agentLookup.resolveByOzonetelId(ozonetelAgentId);
|
||||
if (!agentUuid) return null;
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agentSessions(first: 1, filter: {
|
||||
agentId: { eq: "${agentUuid}" },
|
||||
date: { eq: "${date}" }
|
||||
}) { edges { node {
|
||||
loginDurationS busyTimeS idleTimeS pauseTimeS wrapupTimeS dialTimeS
|
||||
} } } }`,
|
||||
);
|
||||
const node = data?.agentSessions?.edges?.[0]?.node;
|
||||
if (!node) return null;
|
||||
const hms = (sec: number | null | undefined): string => {
|
||||
const s = Math.max(0, Math.round(sec ?? 0));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const r = s % 60;
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${r.toString().padStart(2, '0')}`;
|
||||
};
|
||||
// If the entire rollup is zero, treat as "no data yet" — fall back
|
||||
// to Ozonetel's summaryReport so the KPI isn't all zeroes.
|
||||
const total = (node.loginDurationS ?? 0) + (node.busyTimeS ?? 0) + (node.idleTimeS ?? 0) + (node.pauseTimeS ?? 0) + (node.wrapupTimeS ?? 0);
|
||||
if (total === 0) return null;
|
||||
return {
|
||||
totalLoginDuration: hms(node.loginDurationS),
|
||||
totalBusyTime: hms(node.busyTimeS),
|
||||
totalIdleTime: hms(node.idleTimeS),
|
||||
totalPauseTime: hms(node.pauseTimeS),
|
||||
totalWrapupTime: hms(node.wrapupTimeS),
|
||||
totalDialTime: hms(node.dialTimeS),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@@ -22,6 +23,7 @@ export class MissedCallWebhookController {
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly caller: CallerResolutionService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
@@ -197,6 +199,25 @@ export class MissedCallWebhookController {
|
||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||
}
|
||||
|
||||
// Resolve agent relation at write-time so the supervisor dashboard
|
||||
// can bucket the row immediately. Ozonetel sends transferred calls
|
||||
// with a chain-style AgentName like "RamaiahAdmin -> GlobalHealthX" —
|
||||
// the final handler is the last segment, so split on " -> " and
|
||||
// resolve that. Try both ozonetelAgentId (lowercase unique) and
|
||||
// ozonetelDisplayName (mixed-case human label) since Ozonetel mixes
|
||||
// formats across webhook payloads. Leaves agentId null on miss so
|
||||
// the cdr-enrichment cron can still attempt a match by UCID later.
|
||||
if (data.agentName) {
|
||||
const segments = data.agentName.split('->').map((s) => s.trim()).filter(Boolean);
|
||||
const finalHandler = segments[segments.length - 1];
|
||||
if (finalHandler) {
|
||||
const uuid =
|
||||
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||
if (uuid) callData.agentId = uuid;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||
{ data: callData },
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Test } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
import {
|
||||
WEBHOOK_INBOUND_ANSWERED,
|
||||
WEBHOOK_INBOUND_MISSED,
|
||||
@@ -48,11 +50,28 @@ describe('MissedCallWebhookController', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
const mockCaller = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
leadId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
patientId: '',
|
||||
isNew: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAgentLookup = {
|
||||
resolveByOzonetelId: jest.fn().mockResolvedValue(null),
|
||||
resolveByDisplayName: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [MissedCallWebhookController],
|
||||
providers: [
|
||||
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
||||
{ provide: ConfigService, useValue: mockConfig },
|
||||
{ provide: CallerResolutionService, useValue: mockCaller },
|
||||
{ provide: AgentLookupService, useValue: mockAgentLookup },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export class WorklistService {
|
||||
assignedAgent campaignId
|
||||
contactAttempts spamScore isSpam
|
||||
aiSummary aiSuggestedAction
|
||||
patientId
|
||||
} } pageInfo { hasNextPage endCursor } } }`,
|
||||
'leads',
|
||||
authHeader,
|
||||
|
||||
Reference in New Issue
Block a user