mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
6 Commits
1dd8413297
...
v0.10-apr-
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cf0f69dde | |||
| a6f4c51ca9 | |||
| 2d8308bed8 | |||
| 2666a10f48 | |||
| a00668c517 | |||
| a1413aae40 |
@@ -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);
|
||||||
|
|||||||
@@ -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}}`;
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -959,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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,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);
|
||||||
|
|||||||
@@ -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,7 +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 { 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
|
||||||
@@ -24,6 +25,7 @@ export class MissedCallWebhookController {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly caller: CallerResolutionService,
|
private readonly caller: CallerResolutionService,
|
||||||
private readonly agentLookup: AgentLookupService,
|
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') ?? '';
|
||||||
}
|
}
|
||||||
@@ -126,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'
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -177,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