215 lines
6.5 KiB
TypeScript
215 lines
6.5 KiB
TypeScript
import { businessConfig } from "@/lib/seo-config";
|
|
|
|
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 resendApiKey = String(process.env.RESEND_API_KEY || "").trim();
|
|
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
|
|
const fromEmail = String(process.env.PHONE_CALL_SUMMARY_FROM_EMAIL || "").trim();
|
|
|
|
if (!adminEmail || !resendApiKey || !fromEmail) {
|
|
const missing = [
|
|
!adminEmail ? "ADMIN_EMAIL" : null,
|
|
!resendApiKey ? "RESEND_API_KEY" : null,
|
|
!fromEmail ? "PHONE_CALL_SUMMARY_FROM_EMAIL" : 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 text = [
|
|
`Rocky Mountain Vending phone call summary`,
|
|
``,
|
|
`Caller: ${callerNumber}`,
|
|
`Started: ${formatPhoneCallTimestamp(args.detail.call.startedAt)}`,
|
|
`Duration: ${formatPhoneCallDuration(args.detail.call.durationMs)}`,
|
|
`Call status: ${statusLabel}`,
|
|
`Jessica answered: ${args.detail.call.answered ? "Yes" : "No"}`,
|
|
`Lead outcome: ${args.detail.call.leadOutcome}`,
|
|
`Handoff requested: ${args.detail.call.handoffRequested ? "Yes" : "No"}`,
|
|
`Recording status: ${args.detail.call.recordingStatus || "Unavailable"}`,
|
|
args.detail.call.recordingUrl ? `Recording URL: ${args.detail.call.recordingUrl}` : `Recording URL: Not available in RMV admin`,
|
|
`Summary: ${summaryText}`,
|
|
`Admin call detail: ${callUrl}`,
|
|
args.detail.linkedLead?.id ? `Linked lead: ${args.detail.linkedLead.id}` : `Linked lead: None`,
|
|
``,
|
|
`Recent transcript:`,
|
|
...args.detail.turns.slice(-6).map((turn) => `${turn.role}: ${turn.text}`),
|
|
].join("\n");
|
|
|
|
const response = await fetch("https://api.resend.com/emails", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${resendApiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
from: fromEmail,
|
|
to: [adminEmail],
|
|
subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`,
|
|
text,
|
|
}),
|
|
});
|
|
|
|
const body = (await response.json().catch(() => ({}))) as { message?: string; error?: unknown };
|
|
|
|
if (!response.ok) {
|
|
const errorText =
|
|
typeof body?.message === "string"
|
|
? body.message
|
|
: typeof body?.error === "string"
|
|
? body.error
|
|
: `Resend request failed with status ${response.status}.`;
|
|
|
|
return {
|
|
status: "failed" as const,
|
|
error: errorText,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: "sent" as const,
|
|
error: undefined,
|
|
};
|
|
}
|
|
|
|
export function buildFallbackPhoneCallUrl(callId: string) {
|
|
return `${businessConfig.website.replace(/\/$/, "")}/admin/calls/${callId}`;
|
|
}
|