Groups cdr_feed legs by Correlation ID for call-level summaries, fixes report-column field parsing and Docker proxy routing, and adds /calltest post-call Twilio and CDR enrichment. Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
8 KiB
JavaScript
241 lines
8 KiB
JavaScript
// services/renderers/callTestRenderer.js
|
||
|
||
function fmtMs(ms) {
|
||
if (ms == null || !Number.isFinite(ms)) return '—';
|
||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||
return `${(ms / 1000).toFixed(1)}s`;
|
||
}
|
||
|
||
function fmtTime(iso) {
|
||
if (!iso) return '—';
|
||
try {
|
||
return new Date(iso).toISOString();
|
||
} catch {
|
||
return String(iso);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {object} session
|
||
*/
|
||
export function renderCallTestStartedMarkdown(session) {
|
||
const modeLabel = session.mode === 'store' ? 'Store path' : 'Direct dial';
|
||
const storePart = session.storeNum ? ` for store **${session.storeNum}**` : '';
|
||
return (
|
||
`**Call test started** (${modeLabel})${storePart}\n\n` +
|
||
`- **Dialing:** ${session.dialNumber}\n` +
|
||
`- **Test ID:** \`${session.testId}\`\n` +
|
||
`- Results will post here when the call completes (~2–3 min).`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param {object} session
|
||
*/
|
||
export function renderCallTestResultMarkdown(session) {
|
||
const modeLabel = session.mode === 'store' ? 'Store' : 'Direct';
|
||
const inProgress = session.result == null
|
||
&& (session.status === 'in_progress' || session.status === 'pending');
|
||
const pass = session.result === 'pass';
|
||
const warn = session.result === 'warn';
|
||
const icon = inProgress ? '⏳' : (pass || warn ? '✅' : '❌');
|
||
const headline = inProgress
|
||
? 'IN PROGRESS'
|
||
: (pass || warn ? 'PASSED' : 'FAILED');
|
||
|
||
let md = `**${icon} Call test ${headline}** (${modeLabel})\n\n`;
|
||
|
||
if (session.storeNum) md += `- **Store:** ${session.storeNum}\n`;
|
||
md += `- **Dialed:** ${session.dialNumber}\n`;
|
||
md += `- **Test ID:** \`${session.testId}\`\n`;
|
||
if (session.callSid) md += `- **Twilio CallSid:** \`${session.callSid}\`\n`;
|
||
|
||
if (session.resultReason) {
|
||
md += `- **Result:** ${session.resultReason}\n`;
|
||
}
|
||
if (session.failedAt) {
|
||
md += `- **Failed at:** ${session.failedAt}\n`;
|
||
}
|
||
|
||
const e = session.events || {};
|
||
md += '\n**Timings:**\n';
|
||
if (e.initiated) md += `- Initiated: ${fmtTime(e.initiated)}\n`;
|
||
if (e.ringing) md += `- Ringing: ${fmtTime(e.ringing)}\n`;
|
||
if (e.answered) md += `- Answered: ${fmtTime(e.answered)}\n`;
|
||
if (!e.answered && e['in-progress']) md += `- In progress: ${fmtTime(e['in-progress'])}\n`;
|
||
if (e.completed) md += `- Completed: ${fmtTime(e.completed)}\n`;
|
||
|
||
if (e.answered && e.initiated) {
|
||
md += `- Ring → answer: ${fmtMs(new Date(e.answered) - new Date(e.initiated))}\n`;
|
||
}
|
||
if (e.coreStartedAt) {
|
||
md += `- Core started: ${fmtTime(e.coreStartedAt)}\n`;
|
||
}
|
||
if (e.completed && e.answered) {
|
||
md += `- Answer → complete: ${fmtMs(new Date(e.completed) - new Date(e.answered))}\n`;
|
||
}
|
||
if (session.durationSec != null) {
|
||
md += `- Total duration: ${session.durationSec}s\n`;
|
||
}
|
||
|
||
if (session.mode === 'store' && e.dtmfStepAt) {
|
||
md += `- Entry → DTMF step: ${fmtTime(e.dtmfStepAt)}\n`;
|
||
}
|
||
|
||
return md.trim();
|
||
}
|
||
|
||
/**
|
||
* @param {object} session
|
||
* @param {object} details from fetchCallResource
|
||
*/
|
||
export function renderCallTestTwilioDetailsMarkdown(session, details) {
|
||
let md = `**Call test — Twilio details** (\`${session.testId}\`)\n\n`;
|
||
if (!details?.available) {
|
||
md += `_Unavailable:_ ${details?.reason || 'unknown'}\n`;
|
||
if (session.callSid) md += `- **CallSid:** \`${session.callSid}\`\n`;
|
||
return md.trim();
|
||
}
|
||
|
||
const d = details.data || {};
|
||
md += `- **CallSid:** \`${d.sid || session.callSid}\`\n`;
|
||
md += `- **Status:** ${d.status || '—'}\n`;
|
||
md += `- **Duration:** ${d.duration != null ? `${d.duration}s` : '—'}\n`;
|
||
if (d.startTime) md += `- **Start:** ${fmtTime(d.startTime)}\n`;
|
||
if (d.endTime) md += `- **End:** ${fmtTime(d.endTime)}\n`;
|
||
md += `- **From:** ${d.from || '—'}\n`;
|
||
md += `- **To:** ${d.to || '—'}\n`;
|
||
if (d.price != null) md += `- **Price:** ${d.price} ${d.priceUnit || ''}\n`.trim() + '\n';
|
||
if (d.answeredBy) md += `- **Answered by:** ${d.answeredBy}\n`;
|
||
return md.trim();
|
||
}
|
||
|
||
/**
|
||
* @param {object} session
|
||
* @param {object} insights combined summary + metrics
|
||
*/
|
||
export function renderCallTestTwilioInsightsMarkdown(session, insights) {
|
||
let md = `**Call test — Twilio Insights** (\`${session.testId}\`)\n\n`;
|
||
if (!insights?.available) {
|
||
md += `_Unavailable:_ ${insights?.reason || 'Voice Insights not available'}\n`;
|
||
md += '\n_Enable Voice Insights Advanced Features in Twilio Console for MOS/jitter/loss metrics._\n';
|
||
return md.trim();
|
||
}
|
||
|
||
const s = insights.summary;
|
||
if (s) {
|
||
md += '**Summary**\n';
|
||
if (s.duration != null) md += `- Duration: ${s.duration}s\n`;
|
||
if (s.connectDuration != null) md += `- Connect duration: ${s.connectDuration}s\n`;
|
||
if (s.processingState) md += `- Processing: ${s.processingState}\n`;
|
||
}
|
||
|
||
const m = insights.metrics;
|
||
if (m?.highlights) {
|
||
md += '\n**Quality (carrier edge)**\n';
|
||
const h = m.highlights;
|
||
if (h.maxJitter != null) md += `- Max jitter: ${h.maxJitter}\n`;
|
||
if (h.maxPacketLoss != null) md += `- Max packet loss: ${h.maxPacketLoss}%\n`;
|
||
if (h.minMos != null) md += `- Min MOS: ${h.minMos}\n`;
|
||
}
|
||
|
||
return md.trim();
|
||
}
|
||
|
||
/**
|
||
* @param {object} session
|
||
* @param {object} cdrResult from fetchMatchedCdrForSession
|
||
*/
|
||
export function renderCallTestCdrMatchMarkdown(session, cdrResult) {
|
||
let md = `**Call test — Webex CDR match** (\`${session.testId}\`)\n\n`;
|
||
|
||
if (!cdrResult?.available) {
|
||
md += `_CDR unavailable:_ ${cdrResult?.reason || 'unknown'}\n`;
|
||
return md.trim();
|
||
}
|
||
|
||
if (cdrResult.window) {
|
||
md += `- **Window:** ${cdrResult.window.startTime} → ${cdrResult.window.endTime}\n`;
|
||
}
|
||
if (cdrResult.location) md += `- **Location:** ${cdrResult.location}\n`;
|
||
md += `- **Records in window:** ${cdrResult.rawCount ?? '—'}\n`;
|
||
|
||
const match = cdrResult.match;
|
||
if (!match) {
|
||
md += `\n_No matching leg found._ ${cdrResult.reason || ''}\n`;
|
||
return md.trim();
|
||
}
|
||
|
||
md += '\n**Matched leg**\n';
|
||
md += `- **Start:** ${fmtTime(match.start)}\n`;
|
||
md += `- **Direction:** ${match.direction || '—'}\n`;
|
||
md += `- **Duration:** ${match.duration ?? '—'}s\n`;
|
||
md += `- **Status:** ${match.status || '—'}\n`;
|
||
if (match.callingNumber) md += `- **Calling:** ${match.callingNumber}\n`;
|
||
if (match.calledNumber) md += `- **Called:** ${match.calledNumber}\n`;
|
||
if (match.score != null) md += `- **Match score:** ${match.score}\n`;
|
||
|
||
return md.trim();
|
||
}
|
||
|
||
/**
|
||
* Append enrichment status for /calltest status.
|
||
*/
|
||
export function renderCallTestEnrichmentStatusMarkdown(session) {
|
||
const e = session.enrichment || {};
|
||
const lines = ['\n**Enrichment:**'];
|
||
|
||
const tw = e.twilio || {};
|
||
if (tw.detailsFetchedAt) {
|
||
lines.push(`- Twilio details: ${tw.details?.available ? 'ready' : 'failed'}`);
|
||
} else {
|
||
lines.push('- Twilio details: pending');
|
||
}
|
||
if (tw.insightsFetchedAt) {
|
||
lines.push(`- Twilio Insights: ${tw.insights?.available ? 'ready' : 'unavailable'}`);
|
||
} else {
|
||
lines.push('- Twilio Insights: pending');
|
||
}
|
||
|
||
const cdr = e.cdr;
|
||
if (cdr?.fetchedAt) {
|
||
lines.push(`- Webex CDR: ${cdr.match ? 'matched' : 'no match'}`);
|
||
} else {
|
||
lines.push('- Webex CDR: pending');
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* @param {object} session
|
||
* @param {object} cdr
|
||
*/
|
||
export function renderCallTestCdrMarkdown(session, cdr) {
|
||
if (!cdr?.available) {
|
||
return (
|
||
`**Call test CDR** (\`${session.testId}\`)\n\n` +
|
||
`_Detailed call history unavailable:_ ${cdr?.reason || 'not configured'}`
|
||
);
|
||
}
|
||
|
||
const summary = cdr.summary || {};
|
||
let md =
|
||
`**Call test CDR enrichment** (store **${session.storeNum}**)\n\n` +
|
||
`- **Window:** ${cdr.startTime} → ${cdr.endTime}\n` +
|
||
`- **Location:** ${cdr.location || '—'}\n` +
|
||
`- **Matching legs:** ${summary.totalCalls ?? cdr.rawCount ?? 0}\n`;
|
||
|
||
if (summary.inbound != null) md += `- Inbound legs: ${summary.inbound}\n`;
|
||
if (summary.missed != null) md += `- Missed / failed legs: ${summary.missed}\n`;
|
||
|
||
const samples = cdr.samples || [];
|
||
if (samples.length) {
|
||
md += '\n**Sample legs:**\n';
|
||
for (const s of samples.slice(0, 5)) {
|
||
md += `- ${s.start || '—'} | ${s.direction || '—'} | ${s.duration ?? '—'}s | ${s.otherParty || s.phoneNumber || '—'}\n`;
|
||
}
|
||
}
|
||
|
||
return md.trim();
|
||
}
|