301 lines
9.5 KiB
TypeScript
301 lines
9.5 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
|
|
callerPhone?: string
|
|
pathname?: string
|
|
pageUrl?: string
|
|
source?: string
|
|
contactProfileId?: string
|
|
contactDisplayName?: string
|
|
contactCompany?: 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
|
|
reminderStatus?: "none" | "scheduled" | "sameDay"
|
|
reminderRequestedAt?: number
|
|
reminderStartAt?: number
|
|
reminderEndAt?: number
|
|
reminderCalendarEventId?: string
|
|
reminderCalendarHtmlLink?: string
|
|
reminderNote?: string
|
|
warmTransferStatus?: "none" | "attempted" | "connected" | "failed" | "fallback"
|
|
warmTransferTarget?: string
|
|
warmTransferAttemptedAt?: number
|
|
warmTransferConnectedAt?: number
|
|
warmTransferFailureReason?: 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
|
|
}
|
|
contactProfile: null | {
|
|
_id: string
|
|
normalizedPhone: string
|
|
displayName?: string
|
|
firstName?: string
|
|
lastName?: string
|
|
email?: string
|
|
company?: string
|
|
lastIntent?: string
|
|
lastLeadOutcome?: "none" | "contact" | "requestMachine"
|
|
lastSummaryText?: string
|
|
lastCallAt?: number
|
|
lastReminderAt?: number
|
|
reminderNotes?: string
|
|
}
|
|
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 =
|
|
detail.call.callerPhone ||
|
|
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
|
detail.call.participantIdentity
|
|
const parts = [
|
|
`Caller: ${detail.call.contactDisplayName || callerNumber || "Unknown caller"}.`,
|
|
answeredLabel,
|
|
leadLabel,
|
|
]
|
|
|
|
if (detail.call.contactCompany) {
|
|
parts.push(`Company: ${detail.call.contactCompany}.`)
|
|
}
|
|
|
|
if (detail.call.handoffRequested) {
|
|
parts.push(
|
|
`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`
|
|
)
|
|
}
|
|
|
|
if (detail.call.reminderStatus === "sameDay") {
|
|
parts.push("A same-day follow-up reminder was created for Matt.")
|
|
} else if (detail.call.reminderStatus === "scheduled") {
|
|
parts.push(
|
|
`A follow-up reminder was scheduled for ${formatPhoneCallTimestamp(detail.call.reminderStartAt)}.`
|
|
)
|
|
}
|
|
|
|
if (detail.call.warmTransferStatus && detail.call.warmTransferStatus !== "none") {
|
|
parts.push(
|
|
`Warm transfer status: ${detail.call.warmTransferStatus}${detail.call.warmTransferFailureReason ? ` (${detail.call.warmTransferFailureReason})` : ""}.`
|
|
)
|
|
}
|
|
|
|
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 =
|
|
args.detail.call.callerPhone ||
|
|
normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
|
|
"Unknown caller"
|
|
const callerLabel = args.detail.call.contactDisplayName || callerNumber
|
|
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
|
|
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> ${callerLabel}</p>
|
|
<p><strong>Caller number:</strong> ${callerNumber}</p>
|
|
<p><strong>Company:</strong> ${args.detail.call.contactCompany || "Unknown"}</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>Reminder status:</strong> ${args.detail.call.reminderStatus || "none"}</p>
|
|
<p><strong>Reminder time:</strong> ${formatPhoneCallTimestamp(args.detail.call.reminderStartAt)}</p>
|
|
<p><strong>Reminder link:</strong> ${
|
|
args.detail.call.reminderCalendarHtmlLink
|
|
? `<a href="${args.detail.call.reminderCalendarHtmlLink}">${args.detail.call.reminderCalendarHtmlLink}</a>`
|
|
: "No reminder link"
|
|
}</p>
|
|
<p><strong>Warm transfer:</strong> ${args.detail.call.warmTransferStatus || "none"}</p>
|
|
<p><strong>Warm transfer details:</strong> ${args.detail.call.warmTransferFailureReason || "—"}</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 ${callerLabel}`,
|
|
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}`
|
|
}
|