mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
11 Commits
96977e84a1
...
v0.10-apr-
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cf0f69dde | |||
| a6f4c51ca9 | |||
| 2d8308bed8 | |||
| 2666a10f48 | |||
| a00668c517 | |||
| a1413aae40 | |||
| 6adb3985cb | |||
| 67c41f4783 | |||
| d459d6469a | |||
| 60d2329dd8 | |||
| f375e7736c |
@@ -295,6 +295,54 @@ export class AiChatController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Agent tools — patient lookup, appointments, doctors
|
// Agent tools — patient lookup, appointments, doctors
|
||||||
|
//
|
||||||
|
// UUID safety: LLMs hallucinate 36-char identifiers once the context
|
||||||
|
// starts wearing thin (dropped hyphens, swapped chars). To keep the
|
||||||
|
// model off the UUID path for "this caller" questions, the tools
|
||||||
|
// below accept their id arguments OPTIONALLY — when omitted we fall
|
||||||
|
// back to the leadId carried on the call context, and resolve
|
||||||
|
// patientId from it server-side. The model is instructed (see
|
||||||
|
// ccAgentHelper prompt) to omit the id entirely when asking about
|
||||||
|
// the current caller, so it never has to echo the UUID back.
|
||||||
|
//
|
||||||
|
// Every tool below logs a one-line structured trace via `toolLog`:
|
||||||
|
// [AI-TOOL] <name> args=<...> resolved=<...> result=<...>
|
||||||
|
// This lets us see which tool the model chose, whether it passed
|
||||||
|
// the UUID through or used the context fallback, and what came
|
||||||
|
// back. Tail sidecar logs while testing and you'll see the full
|
||||||
|
// orchestration trail for each chat turn.
|
||||||
|
const logger = this.logger;
|
||||||
|
const toolLog = (name: string, args: Record<string, unknown>, outcome: Record<string, unknown>) => {
|
||||||
|
// Print full values — UUIDs in particular are kept intact so we
|
||||||
|
// can diff the model's argument against the platform record when
|
||||||
|
// hunting hallucinated ids. Grep with `AI-TOOL` to pull the
|
||||||
|
// orchestration trail for a given chat turn.
|
||||||
|
const argStr = Object.entries(args).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' ');
|
||||||
|
const outStr = Object.entries(outcome).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' ');
|
||||||
|
logger.log(`[AI-TOOL] ${name} ${argStr} → ${outStr}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedPatientId: string | undefined;
|
||||||
|
const resolveLeadId = (arg?: string): string | undefined => arg || ctx?.leadId || undefined;
|
||||||
|
const resolvePatientId = async (arg?: string): Promise<string | undefined> => {
|
||||||
|
if (arg) return arg;
|
||||||
|
if (cachedPatientId) return cachedPatientId;
|
||||||
|
const lid = ctx?.leadId;
|
||||||
|
if (!lid) return undefined;
|
||||||
|
try {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ lead(filter: { id: { eq: "${lid}" } }) { id patientId } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
cachedPatientId = data?.lead?.patientId ?? undefined;
|
||||||
|
logger.log(`[AI-TOOL] resolvePatientId lead=${lid} patientId=${cachedPatientId ?? '∅'}`);
|
||||||
|
return cachedPatientId;
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`[AI-TOOL] resolvePatientId failed: ${err?.message ?? err}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const agentTools = {
|
const agentTools = {
|
||||||
lookup_patient: tool({
|
lookup_patient: tool({
|
||||||
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
||||||
@@ -329,24 +377,32 @@ export class AiChatController {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
toolLog('lookup_patient', { phone, name }, { scanned: leads.length, matched: matched.length });
|
||||||
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
||||||
return { found: true, count: matched.length, leads: matched };
|
return { found: true, count: matched.length, leads: matched };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_appointments: tool({
|
lookup_appointments: tool({
|
||||||
description: 'Get appointments for a patient. Returns doctor, department, date, status.',
|
description: 'Get appointments for a patient. Omit patientId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
patientId: z.string().describe('Patient ID'),
|
patientId: z.string().optional().describe('Patient ID (omit when asking about the current caller)'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ patientId }) => {
|
execute: async ({ patientId }) => {
|
||||||
|
const resolved = await resolvePatientId(patientId);
|
||||||
|
if (!resolved) {
|
||||||
|
toolLog('lookup_appointments', { patientId }, { resolved: null, result: 'no-context' });
|
||||||
|
return { appointments: [], message: 'No patient context — ask the agent which patient.' };
|
||||||
|
}
|
||||||
const data = await platformService.queryWithAuth<any>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
`{ appointments(first: 20, filter: { patientId: { eq: "${resolved}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id scheduledAt status doctorName department reasonForVisit
|
id scheduledAt status doctorName department reasonForVisit
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
const appointments = data.appointments.edges.map((e: any) => e.node);
|
||||||
|
toolLog('lookup_appointments', { patientId }, { resolved, count: appointments.length });
|
||||||
|
return { appointments };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -375,7 +431,7 @@ export class AiChatController {
|
|||||||
const full = `${fn} ${ln}`;
|
const full = `${fn} ${ln}`;
|
||||||
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
|
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
|
||||||
});
|
});
|
||||||
this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`);
|
toolLog('lookup_doctor', { doctorName }, { scanned: doctors.length, matched: matched.length });
|
||||||
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` };
|
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` };
|
||||||
return { found: true, doctors: matched };
|
return { found: true, doctors: matched };
|
||||||
},
|
},
|
||||||
@@ -393,7 +449,7 @@ export class AiChatController {
|
|||||||
reason: z.string().describe('Reason for visit'),
|
reason: z.string().describe('Reason for visit'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ patientName, phoneNumber, department, doctorName, clinicId, scheduledAt, reason }) => {
|
execute: async ({ patientName, phoneNumber, department, doctorName, clinicId, scheduledAt, reason }) => {
|
||||||
this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | clinic=${clinicId ?? 'none'} | ${scheduledAt}`);
|
toolLog('book_appointment', { patientName, phoneNumber, department, doctorName, clinicId, scheduledAt }, { reason: reason?.slice(0, 40) });
|
||||||
try {
|
try {
|
||||||
const result = await platformService.queryWithAuth<any>(
|
const result = await platformService.queryWithAuth<any>(
|
||||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
@@ -412,11 +468,13 @@ export class AiChatController {
|
|||||||
);
|
);
|
||||||
const id = result?.createAppointment?.id;
|
const id = result?.createAppointment?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
|
toolLog('book_appointment', { doctorName }, { booked: true, appointmentId: id });
|
||||||
return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` };
|
return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` };
|
||||||
}
|
}
|
||||||
|
toolLog('book_appointment', { doctorName }, { booked: false });
|
||||||
return { booked: false, message: 'Appointment creation failed.' };
|
return { booked: false, message: 'Appointment creation failed.' };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`[TOOL] book_appointment failed: ${err.message}`);
|
logger.error(`[AI-TOOL] book_appointment failed: ${err.message}`);
|
||||||
return { booked: false, message: `Failed to book: ${err.message}` };
|
return { booked: false, message: `Failed to book: ${err.message}` };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -430,7 +488,7 @@ export class AiChatController {
|
|||||||
interest: z.string().describe('What they are enquiring about'),
|
interest: z.string().describe('What they are enquiring about'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ name, phoneNumber, interest }) => {
|
execute: async ({ name, phoneNumber, interest }) => {
|
||||||
this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`);
|
toolLog('create_lead', { name, phoneNumber, interest: interest?.slice(0, 40) }, {});
|
||||||
try {
|
try {
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
const resolved = await this.caller.resolve(cleanPhone, auth);
|
const resolved = await this.caller.resolve(cleanPhone, auth);
|
||||||
@@ -455,7 +513,7 @@ export class AiChatController {
|
|||||||
);
|
);
|
||||||
patientId = p?.createPatient?.id;
|
patientId = p?.createPatient?.id;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`[TOOL] create_lead patient create failed: ${err.message}`);
|
logger.warn(`[AI-TOOL] create_lead patient create failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
const created = await platformService.queryWithAuth<any>(
|
const created = await platformService.queryWithAuth<any>(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
@@ -474,8 +532,10 @@ export class AiChatController {
|
|||||||
);
|
);
|
||||||
const id = created?.createLead?.id;
|
const id = created?.createLead?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
|
toolLog('create_lead', { name }, { created: true, isNew: true, leadId: id });
|
||||||
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||||
}
|
}
|
||||||
|
toolLog('create_lead', { name }, { created: false });
|
||||||
return { created: false, message: 'Lead creation failed.' };
|
return { created: false, message: 'Lead creation failed.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,27 +561,58 @@ export class AiChatController {
|
|||||||
auth,
|
auth,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
|
toolLog('create_lead', { name }, { created: true, isNew: false, leadId: resolved.leadId });
|
||||||
return { created: true, leadId: resolved.leadId, message: `Lead updated for ${name}. Our team will follow up on ${phoneNumber}.` };
|
return { created: true, leadId: resolved.leadId, message: `Lead updated for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`[TOOL] create_lead failed: ${err.message}`);
|
logger.error(`[AI-TOOL] create_lead failed: ${err.message}`);
|
||||||
return { created: false, message: `Failed: ${err.message}` };
|
return { created: false, message: `Failed: ${err.message}` };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_call_history: tool({
|
lookup_call_history: tool({
|
||||||
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
leadId: z.string().describe('Lead ID'),
|
leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ leadId }) => {
|
execute: async ({ leadId }) => {
|
||||||
|
const resolved = resolveLeadId(leadId);
|
||||||
|
if (!resolved) {
|
||||||
|
toolLog('lookup_call_history', { leadId }, { resolved: null, result: 'no-context' });
|
||||||
|
return { calls: [], message: 'No lead context — ask the agent which caller.' };
|
||||||
|
}
|
||||||
const data = await platformService.queryWithAuth<any>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
`{ calls(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id direction callStatus agentName startedAt durationSec disposition
|
id direction callStatus agentName startedAt durationSec disposition
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
return { calls: data.calls.edges.map((e: any) => e.node) };
|
const calls = data.calls.edges.map((e: any) => e.node);
|
||||||
|
toolLog('lookup_call_history', { leadId }, { resolved, count: calls.length });
|
||||||
|
return { calls };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
lookup_lead_activities: tool({
|
||||||
|
description: 'Get activity log entries for a lead — notes, status changes, enquiries. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'),
|
||||||
|
}),
|
||||||
|
execute: async ({ leadId }) => {
|
||||||
|
const resolved = resolveLeadId(leadId);
|
||||||
|
if (!resolved) {
|
||||||
|
toolLog('lookup_lead_activities', { leadId }, { resolved: null, result: 'no-context' });
|
||||||
|
return { activities: [], message: 'No lead context — ask the agent which caller.' };
|
||||||
|
}
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ leadActivities(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||||
|
id activityType summary occurredAt performedBy channel outcome
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const activities = data.leadActivities.edges.map((e: any) => e.node);
|
||||||
|
toolLog('lookup_lead_activities', { leadId }, { resolved, count: activities.length });
|
||||||
|
return { activities };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ export class AgentConfigService {
|
|||||||
return this.telephony.getConfig().sip.wsPort || '444';
|
return this.telephony.getConfig().sip.wsPort || '444';
|
||||||
}
|
}
|
||||||
private get defaultCampaignName(): string {
|
private get defaultCampaignName(): string {
|
||||||
return this.telephony.getConfig().ozonetel.campaignName || 'Inbound_918041763265';
|
// No hardcoded fallback — each Agent entity's own campaignName
|
||||||
|
// field is the source of truth. Env var is the per-workspace
|
||||||
|
// default; if neither is set, the Ozonetel login will use
|
||||||
|
// whatever the agent's entity specifies.
|
||||||
|
return this.telephony.getConfig().ozonetel.campaignName || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||||
|
|||||||
@@ -138,10 +138,9 @@ export class AuthController {
|
|||||||
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$';
|
|
||||||
this.ozonetelAgent.loginAgent({
|
this.ozonetelAgent.loginAgent({
|
||||||
agentId: agentConfig.ozonetelAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: ozAgentPassword,
|
password: agentConfig.sipPassword,
|
||||||
phoneNumber: agentConfig.sipExtension,
|
phoneNumber: agentConfig.sipExtension,
|
||||||
mode: 'blended',
|
mode: 'blended',
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
@@ -250,9 +249,14 @@ export class AuthController {
|
|||||||
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||||
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||||
|
|
||||||
this.ozonetelAgent.logoutAgent({
|
// Await the Ozonetel logout so it completes before the
|
||||||
|
// HTTP response returns. Without this, a fast re-login
|
||||||
|
// (e.g. "remember me" auto-fill) races the logout and
|
||||||
|
// the agent lands in "Telephony Unavailable" because
|
||||||
|
// Ozonetel receives login while still processing logout.
|
||||||
|
await this.ozonetelAgent.logoutAgent({
|
||||||
agentId: agentConfig.ozonetelAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: this.telephony.getConfig().ozonetel.agentPassword || 'Test123$',
|
password: agentConfig.sipPassword,
|
||||||
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||||
|
|
||||||
this.agentConfigService.clearCache(memberId);
|
this.agentConfigService.clearCache(memberId);
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ export class SessionService {
|
|||||||
await this.redis.del(this.key(agentId));
|
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
|
// Generic cache operations for any module
|
||||||
async getCache(key: string): Promise<string | null> {
|
async getCache(key: string): Promise<string | null> {
|
||||||
return this.redis.get(key);
|
return this.redis.get(key);
|
||||||
|
|||||||
@@ -112,13 +112,18 @@ The knowledge base below contains REAL clinic locations, timings, doctor details
|
|||||||
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data. NEVER say a patient doesn't exist without calling a tool first.
|
||||||
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
2. When CURRENT CONTEXT lists a Lead ID, the lookup tools already know which caller to pull. Call them with NO arguments — do not re-type the Lead ID or Patient ID as a tool argument:
|
||||||
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
- lookup_call_history() → calls for this caller
|
||||||
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
|
- lookup_lead_activities() → activity log for this caller
|
||||||
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
- lookup_appointments() → appointments for this caller
|
||||||
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
Pass IDs explicitly only when the agent is asking about a different, specific patient — and even then, prefer name/phone via lookup_patient.
|
||||||
7. Format with bullet points for easy scanning.
|
3. For "summarize this patient's history" or similar, chain multiple lookups (call history + lead activities + appointments) and stitch the answer from what came back. If all three return empty, say so honestly — otherwise report what you found.
|
||||||
|
4. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
||||||
|
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
|
||||||
|
6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||||
|
7. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||||
|
8. Format with bullet points for easy scanning.
|
||||||
|
|
||||||
KNOWLEDGE BASE (this is real data from our system):
|
KNOWLEDGE BASE (this is real data from our system):
|
||||||
{{knowledgeBase}}`;
|
{{knowledgeBase}}`;
|
||||||
|
|||||||
@@ -53,4 +53,20 @@ export class SetupStateController {
|
|||||||
const updated = this.setupState.resetState();
|
const updated = this.setupState.resetState();
|
||||||
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,13 +31,26 @@ export class MaintController {
|
|||||||
async forceReady(@Body() body: { agentId: string }) {
|
async forceReady(@Body() body: { agentId: string }) {
|
||||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||||
const agentId = body.agentId;
|
const agentId = body.agentId;
|
||||||
const oz = this.telephony.getConfig().ozonetel;
|
|
||||||
const password = oz.agentPassword;
|
|
||||||
if (!password) throw new HttpException('agent password not configured', 400);
|
|
||||||
const sipId = oz.sipId;
|
|
||||||
if (!sipId) throw new HttpException('SIP ID not configured', 400);
|
|
||||||
|
|
||||||
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
// Look up the Agent entity to get sipPassword + sipExtension.
|
||||||
|
// Password comes from the Agent record, not an env var — each
|
||||||
|
// agent owns their own Ozonetel credential.
|
||||||
|
const agentData = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 1, filter: { ozonetelAgentId: { eq: "${agentId}" } }) { edges { node {
|
||||||
|
id sipExtension sipPassword
|
||||||
|
} } } }`,
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
const agent = agentData?.agents?.edges?.[0]?.node;
|
||||||
|
if (!agent) throw new HttpException(`Agent ${agentId} not found in platform`, 404);
|
||||||
|
|
||||||
|
const password = agent.sipPassword ?? agent.sipExtension;
|
||||||
|
if (!password) throw new HttpException(`Agent ${agentId} has no sipPassword configured`, 400);
|
||||||
|
|
||||||
|
const sipId = agent.sipExtension;
|
||||||
|
if (!sipId) throw new HttpException(`Agent ${agentId} has no sipExtension configured`, 400);
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Force ready: agent=${agentId} ext=${sipId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ozonetel.logoutAgent({ agentId, password });
|
await this.ozonetel.logoutAgent({ agentId, password });
|
||||||
@@ -56,6 +69,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')
|
@Post('unlock-agent')
|
||||||
async unlockAgent(@Body() body: { agentId: string }) {
|
async unlockAgent(@Body() body: { agentId: string }) {
|
||||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||||
@@ -906,4 +972,110 @@ export class MaintController {
|
|||||||
this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`);
|
this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`);
|
||||||
return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons };
|
return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill disposition + SLA timing on historical calls using CDR data.
|
||||||
|
// Walks calls from a given date (IST), joins to CDR by UCID, and patches
|
||||||
|
// disposition (from CDR's mapped value) + timing fields. Idempotent —
|
||||||
|
// only overwrites null fields (disposition is always overwritten since
|
||||||
|
// the webhook default is unreliable).
|
||||||
|
@Post('backfill-call-disposition-timing')
|
||||||
|
async backfillCallDispositionTiming(@Body() body: { date?: string }) {
|
||||||
|
const date = body.date ?? new Date(Date.now() + 5.5 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
|
this.logger.log(`[MAINT] Backfill disposition+timing for date=${date}`);
|
||||||
|
|
||||||
|
// Fetch CDR for the date
|
||||||
|
const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []);
|
||||||
|
if (cdrRows.length === 0) return { status: 'ok', date, scanned: 0, patched: 0, skipped: 0 };
|
||||||
|
|
||||||
|
// Build UCID + monitorUCID map
|
||||||
|
const byUcid = new Map<string, any>();
|
||||||
|
for (const row of cdrRows) {
|
||||||
|
const ucid = String(row.UCID ?? '').trim();
|
||||||
|
const monUcid = String(row.monitorUCID ?? '').trim();
|
||||||
|
if (ucid) byUcid.set(ucid, row);
|
||||||
|
if (monUcid && monUcid !== ucid) byUcid.set(monUcid, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch calls for the date that have a UCID
|
||||||
|
const gte = `${date}T00:00:00+05:30`;
|
||||||
|
const lte = `${date}T23:59:59+05:30`;
|
||||||
|
const callsData = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 500, filter: {
|
||||||
|
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||||
|
ucid: { is: NOT_NULL }
|
||||||
|
}) { edges { node {
|
||||||
|
id ucid disposition assignedAt answeredAt responseTimeS startedAt
|
||||||
|
} } } }`,
|
||||||
|
).catch(() => ({ calls: { edges: [] } }));
|
||||||
|
|
||||||
|
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
let patched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
const dispositionMap: Record<string, string> = {
|
||||||
|
'General Enquiry': 'INFO_PROVIDED',
|
||||||
|
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||||
|
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||||
|
'Not Interested': 'NOT_INTERESTED',
|
||||||
|
'Wrong Number': 'WRONG_NUMBER',
|
||||||
|
'No Answer': 'NO_ANSWER',
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseHms = (hms: string | null | undefined): number | null => {
|
||||||
|
if (!hms) return null;
|
||||||
|
const parts = String(hms).split(':').map(Number);
|
||||||
|
if (parts.length !== 3 || parts.some(isNaN)) return null;
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||||
|
if (!cdrRow) { skipped++; continue; }
|
||||||
|
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Disposition — always overwrite (webhook default is unreliable)
|
||||||
|
const cdrDisp = dispositionMap[cdrRow.Disposition] ?? null;
|
||||||
|
if (cdrDisp) patch.disposition = cdrDisp;
|
||||||
|
|
||||||
|
// Timing — only fill if null
|
||||||
|
if (!call.answeredAt && cdrRow.AnswerTime) {
|
||||||
|
patch.answeredAt = new Date(cdrRow.AnswerTime).toISOString();
|
||||||
|
}
|
||||||
|
if (!call.assignedAt && cdrRow.StartTime) {
|
||||||
|
patch.assignedAt = new Date(cdrRow.StartTime).toISOString();
|
||||||
|
}
|
||||||
|
if (!call.responseTimeS && call.startedAt && (patch.answeredAt || call.answeredAt)) {
|
||||||
|
const start = new Date(call.startedAt).getTime();
|
||||||
|
const answered = new Date(patch.answeredAt ?? call.answeredAt).getTime();
|
||||||
|
if (!isNaN(start) && !isNaN(answered)) {
|
||||||
|
patch.responseTimeS = Math.max(0, Math.round((answered - start) / 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDR timing fields
|
||||||
|
const handlingSec = parseHms(cdrRow.HandlingTime);
|
||||||
|
const wrapupSec = parseHms(cdrRow.WrapupDuration);
|
||||||
|
const holdSec = parseHms(cdrRow.HoldDuration);
|
||||||
|
if (handlingSec !== null) patch.handlingTimeS = handlingSec;
|
||||||
|
if (wrapupSec !== null) patch.acwDurationS = wrapupSec;
|
||||||
|
if (holdSec !== null) patch.holdDurationS = holdSec;
|
||||||
|
|
||||||
|
if (Object.keys(patch).length === 0) { skipped++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: call.id, data: patch },
|
||||||
|
);
|
||||||
|
patched++;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[MAINT] Backfill patch failed for ${call.id}: ${err.message}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Disposition+timing backfill complete: date=${date} scanned=${calls.length} patched=${patched} skipped=${skipped}`);
|
||||||
|
return { status: 'ok', date, scanned: calls.length, patched, skipped };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,10 +63,18 @@ export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (cdrRows.length === 0) continue;
|
if (cdrRows.length === 0) continue;
|
||||||
|
|
||||||
// Build UCID → cdr-row map so we can O(1) join per Call.
|
// 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>();
|
const byUcid = new Map<string, any>();
|
||||||
for (const row of cdrRows) {
|
for (const row of cdrRows) {
|
||||||
const ucid = String(row.UCID ?? '').trim();
|
const ucid = String(row.UCID ?? '').trim();
|
||||||
|
const monitorUcid = String(row.monitorUCID ?? '').trim();
|
||||||
if (ucid) byUcid.set(ucid, row);
|
if (ucid) byUcid.set(ucid, row);
|
||||||
|
if (monitorUcid && monitorUcid !== ucid) byUcid.set(monitorUcid, row);
|
||||||
}
|
}
|
||||||
if (byUcid.size === 0) continue;
|
if (byUcid.size === 0) continue;
|
||||||
|
|
||||||
@@ -80,9 +88,25 @@ export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!cdrRow) { skipped++; continue; }
|
if (!cdrRow) { skipped++; continue; }
|
||||||
|
|
||||||
const patch: Record<string, any> = {};
|
const patch: Record<string, any> = {};
|
||||||
const cdrAgentId = cdrRow.AgentID;
|
if (!call.agentId) {
|
||||||
if (cdrAgentId && !call.agentId) {
|
// Primary resolution: use AgentID from CDR (unique lowercase id).
|
||||||
const uuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId);
|
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 (uuid) patch.agentId = uuid;
|
||||||
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
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 { Topics } from '../events/event-types';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { SupervisorService } from '../supervisor/supervisor.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.
|
// Convert Ozonetel "HH:MM:SS" (or null/empty) to integer seconds.
|
||||||
// Returns null when input is missing or all-zero.
|
// Returns null when input is missing or all-zero.
|
||||||
@@ -30,6 +31,7 @@ export class OzonetelAgentController {
|
|||||||
private readonly eventBus: EventBusService,
|
private readonly eventBus: EventBusService,
|
||||||
private readonly supervisor: SupervisorService,
|
private readonly supervisor: SupervisorService,
|
||||||
private readonly agentLookup: AgentLookupService,
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
private readonly agentHistory: AgentHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private requireAgentId(agentId: string | undefined | null): string {
|
private requireAgentId(agentId: string | undefined | null): string {
|
||||||
@@ -276,6 +278,34 @@ export class OzonetelAgentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update disposition on answered inbound calls. The webhook creates
|
||||||
|
// the Call record with the Ozonetel default disposition ("General
|
||||||
|
// Enquiry" → INFO_PROVIDED) before the agent disposes. Now that the
|
||||||
|
// agent has submitted their actual disposition, write it back to the
|
||||||
|
// platform Call record by matching on UCID.
|
||||||
|
//
|
||||||
|
// Skipped for outbound (already created with correct disposition
|
||||||
|
// above) and for missed-call callbacks (handled in the block above).
|
||||||
|
if (!body.missedCallId && body.direction !== 'OUTBOUND' && body.ucid) {
|
||||||
|
try {
|
||||||
|
const callData = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 1, filter: { ucid: { eq: "${body.ucid}" } }) { edges { node { id } } } }`,
|
||||||
|
);
|
||||||
|
const callId = callData?.calls?.edges?.[0]?.node?.id;
|
||||||
|
if (callId) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: callId, data: { disposition: body.disposition } },
|
||||||
|
);
|
||||||
|
this.logger.log(`[DISPOSE] Updated inbound call ${callId} disposition → ${body.disposition}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`[DISPOSE] No Call found for ucid=${body.ucid} — disposition not persisted`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[DISPOSE] Failed to update inbound call disposition: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-assign next missed call to this agent
|
// Auto-assign next missed call to this agent
|
||||||
try {
|
try {
|
||||||
await this.missedQueue.assignNext(agentId);
|
await this.missedQueue.assignNext(agentId);
|
||||||
@@ -405,12 +435,28 @@ export class OzonetelAgentController {
|
|||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
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.fetchCDR({ date: targetDate }),
|
||||||
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||||
this.ozonetelAgent.getAHT(agent),
|
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
|
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
||||||
// Use case-insensitive matching — Ozonetel field casing varies
|
// Use case-insensitive matching — Ozonetel field casing varies
|
||||||
const agentLower = agent.toLowerCase();
|
const agentLower = agent.toLowerCase();
|
||||||
@@ -460,7 +506,7 @@ export class OzonetelAgentController {
|
|||||||
avgHandlingTime: aht,
|
avgHandlingTime: aht,
|
||||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||||
appointmentsBooked,
|
appointmentsBooked,
|
||||||
timeUtilization: summary,
|
timeUtilization,
|
||||||
dispositions,
|
dispositions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -480,4 +526,52 @@ export class OzonetelAgentController {
|
|||||||
};
|
};
|
||||||
return map[disposition] ?? 'General Enquiry';
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,4 +52,18 @@ export class SupervisorController {
|
|||||||
} as MessageEvent)),
|
} as MessageEvent)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Worklist SSE — broadcast to all connected agents. When a missed
|
||||||
|
// call is created by the webhook, this fires immediately so agents
|
||||||
|
// don't wait for the 30s worklist poll. The payload includes the
|
||||||
|
// caller's phone + name for a toast notification.
|
||||||
|
@Sse('worklist/stream')
|
||||||
|
streamWorklistUpdates(): Observable<MessageEvent> {
|
||||||
|
this.logger.log('[SSE] Worklist stream opened');
|
||||||
|
return this.supervisor.worklistSubject.pipe(
|
||||||
|
map(event => ({
|
||||||
|
data: JSON.stringify(event),
|
||||||
|
} as MessageEvent)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
||||||
|
// Worklist update stream — emitted when a missed call is created or
|
||||||
|
// assigned. Frontend SSE listener triggers an immediate worklist
|
||||||
|
// refresh so agents see new missed calls without waiting for the 30s poll.
|
||||||
|
readonly worklistSubject = new Subject<{ type: string; callerPhone?: string; callerName?: string; callId?: string; timestamp: string }>();
|
||||||
|
|
||||||
|
emitWorklistUpdate(data: { type: string; callerPhone?: string; callerName?: string; callId?: string }) {
|
||||||
|
this.worklistSubject.next({ ...data, timestamp: new Date().toISOString() });
|
||||||
|
this.logger.log(`[WORKLIST-SSE] ${data.type} phone=${data.callerPhone ?? '?'} name=${data.callerName ?? '?'}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Barge session tracking — key is agentId
|
// Barge session tracking — key is agentId
|
||||||
private readonly bargeSessions = new Map<string, {
|
private readonly bargeSessions = new Map<string, {
|
||||||
@@ -101,6 +110,22 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
eventType: 'CALL_START',
|
eventType: 'CALL_START',
|
||||||
eventAt: iso,
|
eventAt: iso,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Write answeredAt + responseTimeS to the Call record.
|
||||||
|
// Look up the Call by UCID, then patch. The "Calling" event
|
||||||
|
// sets assignedAt (ring start); "Answered" computes response
|
||||||
|
// time as answered - assigned (queue wait time).
|
||||||
|
this.patchCallTimingByUcid(ucid, {
|
||||||
|
answeredAt: iso,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Calling" = agent's phone is ringing → write assignedAt
|
||||||
|
// (the moment the call was routed to this agent).
|
||||||
|
if (action === 'Calling') {
|
||||||
|
this.patchCallTimingByUcid(ucid, {
|
||||||
|
assignedAt: iso,
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} else if (action === 'Disconnect') {
|
} else if (action === 'Disconnect') {
|
||||||
const wasActive = this.activeCalls.get(ucid);
|
const wasActive = this.activeCalls.get(ucid);
|
||||||
@@ -257,10 +282,99 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max plausible call length before the entry is treated as orphaned.
|
||||||
|
// Real Ozonetel calls cap out far short of this — 30 minutes is a safe
|
||||||
|
// ceiling for a hospital call-center context. If a genuinely longer
|
||||||
|
// call existed, losing it from Live Monitor is preferable to the ghost
|
||||||
|
// state (supervisors lose trust in the dashboard otherwise).
|
||||||
|
private static readonly MAX_ACTIVE_CALL_AGE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
// Agent states that are incompatible with having an active call. If the
|
||||||
|
// mapped agent is currently in one of these, the activeCalls entry is
|
||||||
|
// definitely stale (e.g. Disconnect webhook was dropped).
|
||||||
|
private static readonly NON_CALL_AGENT_STATES = new Set(['ready', 'offline', 'paused']);
|
||||||
|
|
||||||
getActiveCalls(): ActiveCall[] {
|
getActiveCalls(): ActiveCall[] {
|
||||||
|
// Sweep stale entries before returning. The activeCalls Map is a
|
||||||
|
// best-effort in-memory projection of Ozonetel call events — if
|
||||||
|
// Ozonetel drops a Disconnect (network blip, subscription hiccup,
|
||||||
|
// sidecar restart mid-call), the entry lingers forever and the
|
||||||
|
// Live Call Monitor shows a ghost call with a runaway timer.
|
||||||
|
//
|
||||||
|
// Two signals identify staleness:
|
||||||
|
// 1. The associated agent is not in a busy state (ready, offline,
|
||||||
|
// paused — they can't be on a call).
|
||||||
|
// 2. startTime is older than MAX_ACTIVE_CALL_AGE_MS (hard ceiling
|
||||||
|
// regardless of agent-state signal).
|
||||||
|
const now = Date.now();
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const [ucid, call] of this.activeCalls.entries()) {
|
||||||
|
const ageMs = now - new Date(call.startTime).getTime();
|
||||||
|
if (isNaN(ageMs)) continue;
|
||||||
|
|
||||||
|
if (ageMs > SupervisorService.MAX_ACTIVE_CALL_AGE_MS) {
|
||||||
|
toDelete.push(ucid);
|
||||||
|
this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} (age ${Math.round(ageMs / 60000)}m, exceeds ${SupervisorService.MAX_ACTIVE_CALL_AGE_MS / 60000}m cap)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentState = this.agentStates.get(call.agentId)?.state;
|
||||||
|
if (agentState && SupervisorService.NON_CALL_AGENT_STATES.has(agentState)) {
|
||||||
|
toDelete.push(ucid);
|
||||||
|
this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} — agent ${call.agentId} is ${agentState}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ucid of toDelete) this.activeCalls.delete(ucid);
|
||||||
|
|
||||||
return Array.from(this.activeCalls.values());
|
return Array.from(this.activeCalls.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up a Call by UCID and patch its timing fields. Used by
|
||||||
|
// handleCallEvent to write assignedAt/answeredAt in real-time.
|
||||||
|
// Also computes responseTimeS when answeredAt is written and
|
||||||
|
// the Call already has a startedAt.
|
||||||
|
private async patchCallTimingByUcid(ucid: string, fields: {
|
||||||
|
assignedAt?: string;
|
||||||
|
answeredAt?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id startedAt assignedAt } } } }`,
|
||||||
|
);
|
||||||
|
const call = data?.calls?.edges?.[0]?.node;
|
||||||
|
if (!call) {
|
||||||
|
this.logger.warn(`[SLA] No Call for ucid=${ucid} — timing not written`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
if (fields.assignedAt) patch.assignedAt = fields.assignedAt;
|
||||||
|
if (fields.answeredAt) {
|
||||||
|
patch.answeredAt = fields.answeredAt;
|
||||||
|
// Compute response time: answered - started (how long the
|
||||||
|
// caller waited from call creation to agent pickup).
|
||||||
|
const start = call.startedAt ? new Date(call.startedAt).getTime() : null;
|
||||||
|
const answered = new Date(fields.answeredAt).getTime();
|
||||||
|
if (start && !isNaN(start) && !isNaN(answered)) {
|
||||||
|
const responseS = Math.max(0, Math.round((answered - start) / 1000));
|
||||||
|
patch.responseTimeS = responseS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: call.id, data: patch },
|
||||||
|
);
|
||||||
|
this.logger.log(`[SLA] Patched call ${call.id} — ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[SLA] patchCallTimingByUcid failed for ${ucid}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getTeamPerformance(date: string): Promise<any> {
|
async getTeamPerformance(date: string): Promise<any> {
|
||||||
// Get all agents from platform. Field names are label-derived
|
// Get all agents from platform. Field names are label-derived
|
||||||
// camelCase on the current platform schema — see
|
// camelCase on the current platform schema — see
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
@@ -22,6 +24,8 @@ export class MissedCallWebhookController {
|
|||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly caller: CallerResolutionService,
|
private readonly caller: CallerResolutionService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
@Inject(forwardRef(() => SupervisorService)) private readonly supervisor: SupervisorService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -124,6 +128,15 @@ export class MissedCallWebhookController {
|
|||||||
|
|
||||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
|
||||||
|
// Push worklist SSE so agents see new calls instantly
|
||||||
|
// instead of waiting for the 30s frontend poll.
|
||||||
|
this.supervisor.emitWorklistUpdate({
|
||||||
|
type: callStatus === 'MISSED' ? 'missed-call' : 'inbound-call',
|
||||||
|
callerPhone: callerPhone,
|
||||||
|
callerName: resolved.leadName ?? undefined,
|
||||||
|
callId,
|
||||||
|
});
|
||||||
|
|
||||||
// Step 3: Lead-side side-effects (activity log + contact stats)
|
// Step 3: Lead-side side-effects (activity log + contact stats)
|
||||||
if (resolved.leadId) {
|
if (resolved.leadId) {
|
||||||
const summary = callStatus === 'MISSED'
|
const summary = callStatus === 'MISSED'
|
||||||
@@ -197,6 +210,25 @@ export class MissedCallWebhookController {
|
|||||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
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>(
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||||
{ data: callData },
|
{ data: callData },
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Test } from '@nestjs/testing';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import {
|
import {
|
||||||
WEBHOOK_INBOUND_ANSWERED,
|
WEBHOOK_INBOUND_ANSWERED,
|
||||||
WEBHOOK_INBOUND_MISSED,
|
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({
|
const module = await Test.createTestingModule({
|
||||||
controllers: [MissedCallWebhookController],
|
controllers: [MissedCallWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
||||||
{ provide: ConfigService, useValue: mockConfig },
|
{ provide: ConfigService, useValue: mockConfig },
|
||||||
|
{ provide: CallerResolutionService, useValue: mockCaller },
|
||||||
|
{ provide: AgentLookupService, useValue: mockAgentLookup },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { WorklistController } from './worklist.controller';
|
import { WorklistController } from './worklist.controller';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
@@ -12,7 +13,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
|||||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule)],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule), forwardRef(() => SupervisorModule)],
|
||||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||||
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
||||||
exports: [MissedQueueService],
|
exports: [MissedQueueService],
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export class WorklistService {
|
|||||||
assignedAgent campaignId
|
assignedAgent campaignId
|
||||||
contactAttempts spamScore isSpam
|
contactAttempts spamScore isSpam
|
||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
|
patientId
|
||||||
} } pageInfo { hasNextPage endCursor } } }`,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
'leads',
|
'leads',
|
||||||
authHeader,
|
authHeader,
|
||||||
@@ -176,6 +177,7 @@ export class WorklistService {
|
|||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
disposition leadId leadName
|
disposition leadId leadName
|
||||||
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
||||||
|
campaign { id campaignName }
|
||||||
} } pageInfo { hasNextPage endCursor } } }`,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
'calls',
|
'calls',
|
||||||
authHeader,
|
authHeader,
|
||||||
|
|||||||
Reference in New Issue
Block a user