Rocky_Mountain_Vending/lib/phone-calls.ts

216 lines
6.9 KiB
TypeScript

import { businessConfig } from "@/lib/seo-config";
import { isEmailConfigured, sendTransactionalEmail } from "@/lib/email";
export type AdminPhoneCallTurn = {
id: string;
role: string;
text: string;
source?: string;
kind?: string;
createdAt: number;
};
export type AdminPhoneCallDetail = {
call: {
id: string;
roomName: string;
participantIdentity: string;
pathname?: string;
pageUrl?: string;
source?: string;
startedAt: number;
endedAt?: number;
durationMs: number | null;
callStatus: "started" | "completed" | "failed";
transcriptTurnCount: number;
answered: boolean;
agentAnsweredAt?: number;
linkedLeadId?: string;
leadOutcome: "none" | "contact" | "requestMachine";
handoffRequested: boolean;
handoffReason?: string;
summaryText?: string;
notificationStatus: "pending" | "sent" | "failed" | "disabled";
notificationSentAt?: number;
notificationError?: string;
recordingStatus?: "pending" | "starting" | "recording" | "completed" | "failed";
recordingUrl?: string;
recordingError?: string;
};
linkedLead: null | {
id: string;
type: "contact" | "requestMachine";
status: "pending" | "delivered" | "failed";
firstName: string;
lastName: string;
email: string;
phone: string;
company?: string;
intent?: string;
message?: string;
createdAt: number;
};
turns: AdminPhoneCallTurn[];
};
export function formatPhoneCallTimestamp(timestamp?: number) {
if (!timestamp) {
return "—";
}
return new Date(timestamp).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function formatPhoneCallDuration(durationMs: number | null | undefined) {
if (typeof durationMs !== "number" || durationMs <= 0) {
return "—";
}
const totalSeconds = Math.round(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export function normalizePhoneFromIdentity(identity?: string) {
const digits = String(identity || "").replace(/\D/g, "");
if (!digits) {
return "";
}
if (digits.length === 10) {
return `+1${digits}`;
}
if (digits.length === 11 && digits.startsWith("1")) {
return `+${digits}`;
}
return `+${digits}`;
}
export function buildPhoneCallSummary(detail: Pick<AdminPhoneCallDetail, "call" | "linkedLead" | "turns">) {
const answeredLabel = detail.call.answered ? "Jessica answered the call." : "Jessica did not fully answer the call.";
const leadLabel =
detail.call.leadOutcome === "none"
? "No lead was submitted."
: detail.call.leadOutcome === "requestMachine"
? "A machine request lead was submitted."
: "A contact lead was submitted.";
const lastMeaningfulUserTurn = [...detail.turns]
.reverse()
.find((turn) => turn.role === "user" && turn.text.trim());
const leadMessage =
detail.linkedLead?.message?.trim() ||
detail.linkedLead?.intent?.trim() ||
lastMeaningfulUserTurn?.text.trim() ||
"";
const callerNumber = normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity;
const parts = [
`Caller: ${callerNumber || "Unknown caller"}.`,
answeredLabel,
leadLabel,
];
if (detail.call.handoffRequested) {
parts.push(`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`);
}
if (leadMessage) {
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`);
}
return parts.join(" ");
}
export async function sendPhoneCallSummaryEmail(args: {
detail: AdminPhoneCallDetail;
adminUrl: string;
}) {
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
const fromEmail = String(process.env.PHONE_CALL_SUMMARY_FROM_EMAIL || "").trim();
if (!adminEmail || !fromEmail || !isEmailConfigured()) {
const missing = [
!adminEmail ? "ADMIN_EMAIL" : null,
!fromEmail ? "PHONE_CALL_SUMMARY_FROM_EMAIL" : null,
!isEmailConfigured() ? "email transport" : null,
].filter(Boolean);
return {
status: "disabled" as const,
error: `${missing.join(", ")} is not configured.`,
};
}
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`;
const summaryText = buildPhoneCallSummary(args.detail);
const callerNumber = normalizePhoneFromIdentity(args.detail.call.participantIdentity) || "Unknown caller";
const statusLabel = args.detail.call.callStatus.toUpperCase();
const transcriptHtml = args.detail.turns
.slice(-6)
.map((turn) => {
const role = turn.role.replace(/[<>&"]/g, "");
const text = turn.text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return `<li><strong>${role}:</strong> ${text}</li>`;
})
.join("");
const html = `
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.6;">
<h1 style="font-size: 20px; margin-bottom: 16px;">Rocky Mountain Vending phone call summary</h1>
<p><strong>Caller:</strong> ${callerNumber}</p>
<p><strong>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p>
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
<p><strong>Call status:</strong> ${statusLabel}</p>
<p><strong>Jessica answered:</strong> ${args.detail.call.answered ? "Yes" : "No"}</p>
<p><strong>Lead outcome:</strong> ${args.detail.call.leadOutcome}</p>
<p><strong>Handoff requested:</strong> ${args.detail.call.handoffRequested ? "Yes" : "No"}</p>
<p><strong>Recording status:</strong> ${args.detail.call.recordingStatus || "Unavailable"}</p>
<p><strong>Recording URL:</strong> ${
args.detail.call.recordingUrl
? `<a href="${args.detail.call.recordingUrl}">${args.detail.call.recordingUrl}</a>`
: "Not available in RMV admin"
}</p>
<p><strong>Summary:</strong> ${summaryText}</p>
<p><strong>Admin call detail:</strong> <a href="${callUrl}">${callUrl}</a></p>
<p><strong>Linked lead:</strong> ${args.detail.linkedLead?.id || "None"}</p>
<h2 style="font-size: 16px; margin-top: 24px;">Recent transcript</h2>
<ul>${transcriptHtml || "<li>No transcript turns were captured.</li>"}</ul>
</div>
`;
try {
await sendTransactionalEmail({
from: fromEmail,
to: adminEmail,
subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`,
html,
});
return {
status: "sent" as const,
error: undefined,
};
} catch (error) {
return {
status: "failed" as const,
error: error instanceof Error ? error.message : "Failed to send phone call summary email.",
};
}
}
export function buildFallbackPhoneCallUrl(callId: string) {
return `${businessConfig.website.replace(/\/$/, "")}/admin/calls/${callId}`;
}