feat: add local RMV tool stack for phone agent
This commit is contained in:
parent
8fff380b24
commit
bc2edc04f2
21 changed files with 1627 additions and 19 deletions
|
|
@ -33,6 +33,15 @@ ADMIN_EMAIL=
|
||||||
# Direct phone-call visibility
|
# Direct phone-call visibility
|
||||||
PHONE_AGENT_INTERNAL_TOKEN=
|
PHONE_AGENT_INTERNAL_TOKEN=
|
||||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||||
|
ENABLE_GHL_SYNC=false
|
||||||
|
GOOGLE_CALENDAR_CLIENT_ID=
|
||||||
|
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||||
|
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
||||||
|
GOOGLE_CALENDAR_ID=
|
||||||
|
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
||||||
|
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
||||||
|
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
||||||
|
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
||||||
|
|
||||||
# Placeholder for a later LiveKit rollout
|
# Placeholder for a later LiveKit rollout
|
||||||
LIVEKIT_URL=
|
LIVEKIT_URL=
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ ADMIN_EMAIL=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||||
|
ENABLE_GHL_SYNC=false
|
||||||
|
GOOGLE_CALENDAR_CLIENT_ID=
|
||||||
|
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||||
|
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
||||||
|
GOOGLE_CALENDAR_ID=
|
||||||
|
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
||||||
|
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
||||||
|
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
||||||
|
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
Phone Call Detail
|
Phone Call Detail
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
{detail.call.contactDisplayName ||
|
||||||
|
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||||
detail.call.participantIdentity}
|
detail.call.participantIdentity}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,6 +99,22 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
{detail.call.participantIdentity || "Unknown"}
|
{detail.call.participantIdentity || "Unknown"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Caller Phone
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{detail.call.callerPhone ||
|
||||||
|
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||||
|
"Unknown"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Company
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{detail.call.contactCompany || "—"}</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
Call Status
|
Call Status
|
||||||
|
|
@ -135,6 +152,22 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium">{detail.call.notificationStatus}</p>
|
<p className="font-medium">{detail.call.notificationStatus}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Reminder
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{detail.call.reminderStatus || "none"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Warm Transfer
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{detail.call.warmTransferStatus || "none"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
Summary
|
Summary
|
||||||
|
|
@ -157,6 +190,26 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{detail.call.reminderStartAt ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Reminder Time
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatPhoneCallTimestamp(detail.call.reminderStartAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detail.call.warmTransferFailureReason ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Transfer Detail
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{detail.call.warmTransferFailureReason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{detail.call.recordingUrl ? (
|
{detail.call.recordingUrl ? (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -179,6 +232,18 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{detail.call.reminderCalendarHtmlLink ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Link
|
||||||
|
href={detail.call.reminderCalendarHtmlLink}
|
||||||
|
target="_blank"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open reminder
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -237,6 +302,23 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
||||||
lead.
|
lead.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{detail.contactProfile ? (
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Contact Profile
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{detail.contactProfile.displayName ||
|
||||||
|
[detail.contactProfile.firstName, detail.contactProfile.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") ||
|
||||||
|
"Known caller"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{detail.contactProfile.company || detail.contactProfile.email || "No company or email yet"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[1050px] text-sm">
|
<table className="w-full min-w-[1240px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left text-muted-foreground">
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
<th className="py-3 pr-4 font-medium">Caller</th>
|
<th className="py-3 pr-4 font-medium">Caller</th>
|
||||||
|
|
@ -116,6 +116,8 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
<th className="py-3 pr-4 font-medium">Recording</th>
|
<th className="py-3 pr-4 font-medium">Recording</th>
|
||||||
<th className="py-3 pr-4 font-medium">Lead</th>
|
<th className="py-3 pr-4 font-medium">Lead</th>
|
||||||
<th className="py-3 pr-4 font-medium">Email</th>
|
<th className="py-3 pr-4 font-medium">Email</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Reminder</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Transfer</th>
|
||||||
<th className="py-3 pr-4 font-medium">Summary</th>
|
<th className="py-3 pr-4 font-medium">Summary</th>
|
||||||
<th className="py-3 font-medium">Open</th>
|
<th className="py-3 font-medium">Open</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -124,7 +126,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={11}
|
colSpan={13}
|
||||||
className="py-8 text-center text-muted-foreground"
|
className="py-8 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
No phone calls matched this filter.
|
No phone calls matched this filter.
|
||||||
|
|
@ -138,12 +140,18 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
>
|
>
|
||||||
<td className="py-3 pr-4 font-medium">
|
<td className="py-3 pr-4 font-medium">
|
||||||
<div>
|
<div>
|
||||||
{normalizePhoneFromIdentity(
|
{call.contactDisplayName ||
|
||||||
call.participantIdentity
|
normalizePhoneFromIdentity(
|
||||||
) || call.participantIdentity}
|
call.participantIdentity
|
||||||
|
) ||
|
||||||
|
call.participantIdentity}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{call.roomName}
|
{call.contactCompany ||
|
||||||
|
normalizePhoneFromIdentity(
|
||||||
|
call.participantIdentity
|
||||||
|
) ||
|
||||||
|
call.roomName}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4">
|
<td className="py-3 pr-4">
|
||||||
|
|
@ -172,6 +180,16 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
||||||
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
|
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4">{call.notificationStatus}</td>
|
<td className="py-3 pr-4">{call.notificationStatus}</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{call.reminderStatus === "none"
|
||||||
|
? "—"
|
||||||
|
: call.reminderStatus}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{call.warmTransferStatus === "none"
|
||||||
|
? "—"
|
||||||
|
: call.warmTransferStatus}
|
||||||
|
</td>
|
||||||
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
|
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
|
||||||
<span className="line-clamp-2">
|
<span className="line-clamp-2">
|
||||||
{call.summaryText || "No summary yet"}
|
{call.summaryText || "No summary yet"}
|
||||||
|
|
|
||||||
40
app/api/internal/phone-agent/contact-lookup/route.ts
Normal file
40
app/api/internal/phone-agent/contact-lookup/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { fetchQuery } from "convex/nextjs"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||||
|
import { normalizePhoneE164 } from "@/lib/phone-normalization"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requirePhoneAgentInternalAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const normalizedPhone = normalizePhoneE164(body.phone)
|
||||||
|
|
||||||
|
if (!normalizedPhone) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "phone is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
||||||
|
normalizedPhone,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
normalizedPhone,
|
||||||
|
...context,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to look up phone agent contact context:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to look up phone agent contact context" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
161
app/api/internal/phone-agent/followup-reminder/route.ts
Normal file
161
app/api/internal/phone-agent/followup-reminder/route.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { fetchMutation } from "convex/nextjs"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||||
|
import {
|
||||||
|
buildSameDayReminderWindow,
|
||||||
|
createFollowupReminderEvent,
|
||||||
|
} from "@/lib/google-calendar"
|
||||||
|
import { normalizePhoneE164, splitDisplayName } from "@/lib/phone-normalization"
|
||||||
|
|
||||||
|
function buildReminderTitle(args: {
|
||||||
|
kind: "scheduled" | "same-day"
|
||||||
|
callerName?: string
|
||||||
|
company?: string
|
||||||
|
phone?: string
|
||||||
|
}) {
|
||||||
|
const label = args.kind === "same-day" ? "Same-day callback" : "Callback reminder"
|
||||||
|
const identity = [args.callerName, args.company, args.phone]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" | ")
|
||||||
|
|
||||||
|
return identity ? `${label}: ${identity}` : label
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReminderDescription(args: {
|
||||||
|
callerName?: string
|
||||||
|
company?: string
|
||||||
|
phone?: string
|
||||||
|
reason?: string
|
||||||
|
summaryText?: string
|
||||||
|
adminCallUrl: string
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
args.callerName ? `Caller: ${args.callerName}` : "",
|
||||||
|
args.company ? `Company: ${args.company}` : "",
|
||||||
|
args.phone ? `Phone: ${args.phone}` : "",
|
||||||
|
args.reason ? `Reason: ${args.reason}` : "",
|
||||||
|
args.summaryText ? `Summary: ${args.summaryText}` : "",
|
||||||
|
`RMV admin call detail: ${args.adminCallUrl}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requirePhoneAgentInternalAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const sessionId = String(body.sessionId || "").trim()
|
||||||
|
const kind =
|
||||||
|
body.kind === "same-day" ? ("same-day" as const) : ("scheduled" as const)
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "sessionId is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const adminCallUrl = `${url.origin}/admin/calls/${sessionId}`
|
||||||
|
const normalizedPhone = normalizePhoneE164(body.phone)
|
||||||
|
const callerName = String(body.callerName || "").trim()
|
||||||
|
const company = String(body.company || "").trim()
|
||||||
|
const reason = String(body.reason || "").trim()
|
||||||
|
const summaryText = String(body.summaryText || "").trim()
|
||||||
|
|
||||||
|
let startAt: Date
|
||||||
|
let endAt: Date
|
||||||
|
if (kind === "same-day") {
|
||||||
|
const reminderWindow = buildSameDayReminderWindow()
|
||||||
|
startAt = reminderWindow.startAt
|
||||||
|
endAt = reminderWindow.endAt
|
||||||
|
} else {
|
||||||
|
startAt = new Date(String(body.startAt || ""))
|
||||||
|
endAt = new Date(String(body.endAt || ""))
|
||||||
|
if (Number.isNaN(endAt.getTime()) && !Number.isNaN(startAt.getTime())) {
|
||||||
|
endAt = new Date(startAt.getTime() + 15 * 60 * 1000)
|
||||||
|
}
|
||||||
|
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || startAt.getTime() <= Date.now()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "A future startAt and endAt are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminder = await createFollowupReminderEvent({
|
||||||
|
title: buildReminderTitle({
|
||||||
|
kind,
|
||||||
|
callerName,
|
||||||
|
company,
|
||||||
|
phone: normalizedPhone || String(body.phone || "").trim(),
|
||||||
|
}),
|
||||||
|
description: buildReminderDescription({
|
||||||
|
callerName,
|
||||||
|
company,
|
||||||
|
phone: normalizedPhone || String(body.phone || "").trim(),
|
||||||
|
reason,
|
||||||
|
summaryText,
|
||||||
|
adminCallUrl,
|
||||||
|
}),
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
let contactProfileId: string | undefined
|
||||||
|
if (normalizedPhone) {
|
||||||
|
const nameParts = splitDisplayName(callerName)
|
||||||
|
const profile = await fetchMutation(api.contactProfiles.upsertByPhone, {
|
||||||
|
normalizedPhone,
|
||||||
|
displayName: callerName || undefined,
|
||||||
|
firstName: nameParts.firstName || undefined,
|
||||||
|
lastName: nameParts.lastName || undefined,
|
||||||
|
company: company || undefined,
|
||||||
|
lastSummaryText: summaryText || reason || undefined,
|
||||||
|
lastReminderAt: Date.now(),
|
||||||
|
reminderNotes: reason || undefined,
|
||||||
|
source: "phone-agent",
|
||||||
|
})
|
||||||
|
contactProfileId = profile?._id
|
||||||
|
}
|
||||||
|
|
||||||
|
const call = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
||||||
|
sessionId,
|
||||||
|
contactProfileId,
|
||||||
|
contactDisplayName: callerName || undefined,
|
||||||
|
contactCompany: company || undefined,
|
||||||
|
reminderStatus: kind === "same-day" ? "sameDay" : "scheduled",
|
||||||
|
reminderRequestedAt: Date.now(),
|
||||||
|
reminderStartAt: startAt.getTime(),
|
||||||
|
reminderEndAt: endAt.getTime(),
|
||||||
|
reminderCalendarEventId: reminder.eventId,
|
||||||
|
reminderCalendarHtmlLink: reminder.htmlLink || undefined,
|
||||||
|
reminderNote: reason || summaryText || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
reminder: {
|
||||||
|
kind,
|
||||||
|
startAt: startAt.toISOString(),
|
||||||
|
endAt: endAt.toISOString(),
|
||||||
|
eventId: reminder.eventId,
|
||||||
|
htmlLink: reminder.htmlLink,
|
||||||
|
},
|
||||||
|
call,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create phone agent follow-up reminder:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create phone agent follow-up reminder" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/internal/phone-agent/followup-slots/route.ts
Normal file
30
app/api/internal/phone-agent/followup-slots/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||||
|
import { listFutureCallbackSlots } from "@/lib/google-calendar"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requirePhoneAgentInternalAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const limit =
|
||||||
|
typeof body.limit === "number" && body.limit > 0
|
||||||
|
? Math.min(body.limit, 5)
|
||||||
|
: 3
|
||||||
|
|
||||||
|
const slots = await listFutureCallbackSlots(limit)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
slots,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to list phone agent callback slots:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to list phone agent callback slots" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/api/internal/phone-agent/service-knowledge/route.ts
Normal file
37
app/api/internal/phone-agent/service-knowledge/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||||
|
import { searchServiceKnowledge } from "@/lib/service-knowledge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requirePhoneAgentInternalAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const query = String(body.query || "").trim()
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json({ error: "query is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await searchServiceKnowledge({
|
||||||
|
query,
|
||||||
|
limit:
|
||||||
|
typeof body.limit === "number" && body.limit > 0
|
||||||
|
? Math.min(body.limit, 6)
|
||||||
|
: 4,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to search phone agent service knowledge:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to search phone agent service knowledge" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,13 @@ export async function POST(request: Request) {
|
||||||
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
||||||
sessionId: body.sessionId,
|
sessionId: body.sessionId,
|
||||||
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
|
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
|
||||||
|
contactProfileId: body.contactProfileId || undefined,
|
||||||
|
contactDisplayName: body.contactDisplayName
|
||||||
|
? String(body.contactDisplayName)
|
||||||
|
: undefined,
|
||||||
|
contactCompany: body.contactCompany
|
||||||
|
? String(body.contactCompany)
|
||||||
|
: undefined,
|
||||||
leadOutcome: body.leadOutcome || "none",
|
leadOutcome: body.leadOutcome || "none",
|
||||||
handoffRequested:
|
handoffRequested:
|
||||||
typeof body.handoffRequested === "boolean"
|
typeof body.handoffRequested === "boolean"
|
||||||
|
|
@ -22,6 +29,41 @@ export async function POST(request: Request) {
|
||||||
handoffReason: body.handoffReason
|
handoffReason: body.handoffReason
|
||||||
? String(body.handoffReason)
|
? String(body.handoffReason)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
reminderStatus: body.reminderStatus || undefined,
|
||||||
|
reminderRequestedAt:
|
||||||
|
typeof body.reminderRequestedAt === "number"
|
||||||
|
? body.reminderRequestedAt
|
||||||
|
: undefined,
|
||||||
|
reminderStartAt:
|
||||||
|
typeof body.reminderStartAt === "number"
|
||||||
|
? body.reminderStartAt
|
||||||
|
: undefined,
|
||||||
|
reminderEndAt:
|
||||||
|
typeof body.reminderEndAt === "number"
|
||||||
|
? body.reminderEndAt
|
||||||
|
: undefined,
|
||||||
|
reminderCalendarEventId: body.reminderCalendarEventId
|
||||||
|
? String(body.reminderCalendarEventId)
|
||||||
|
: undefined,
|
||||||
|
reminderCalendarHtmlLink: body.reminderCalendarHtmlLink
|
||||||
|
? String(body.reminderCalendarHtmlLink)
|
||||||
|
: undefined,
|
||||||
|
reminderNote: body.reminderNote ? String(body.reminderNote) : undefined,
|
||||||
|
warmTransferStatus: body.warmTransferStatus || undefined,
|
||||||
|
warmTransferTarget: body.warmTransferTarget
|
||||||
|
? String(body.warmTransferTarget)
|
||||||
|
: undefined,
|
||||||
|
warmTransferAttemptedAt:
|
||||||
|
typeof body.warmTransferAttemptedAt === "number"
|
||||||
|
? body.warmTransferAttemptedAt
|
||||||
|
: undefined,
|
||||||
|
warmTransferConnectedAt:
|
||||||
|
typeof body.warmTransferConnectedAt === "number"
|
||||||
|
? body.warmTransferConnectedAt
|
||||||
|
: undefined,
|
||||||
|
warmTransferFailureReason: body.warmTransferFailureReason
|
||||||
|
? String(body.warmTransferFailureReason)
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true, call: result })
|
return NextResponse.json({ success: true, call: result })
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { fetchMutation } from "convex/nextjs"
|
import { fetchMutation, fetchQuery } from "convex/nextjs"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||||
|
import { normalizePhoneE164 } from "@/lib/phone-normalization"
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requirePhoneAgentInternalAuth(request)
|
const authError = await requirePhoneAgentInternalAuth(request)
|
||||||
|
|
@ -11,16 +12,44 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
let metadata: Record<string, unknown> = {}
|
||||||
|
if (typeof body.metadata === "string" && body.metadata.trim()) {
|
||||||
|
try {
|
||||||
|
metadata = JSON.parse(body.metadata)
|
||||||
|
} catch {
|
||||||
|
metadata = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const callerPhone = normalizePhoneE164(
|
||||||
|
metadata.participantPhone || body.participantIdentity
|
||||||
|
)
|
||||||
|
const contactContext = callerPhone
|
||||||
|
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
||||||
|
normalizedPhone: callerPhone,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
const result = await fetchMutation(
|
const result = await fetchMutation(
|
||||||
api.voiceSessions.upsertPhoneCallSession,
|
api.voiceSessions.upsertPhoneCallSession,
|
||||||
{
|
{
|
||||||
roomName: String(body.roomName || ""),
|
roomName: String(body.roomName || ""),
|
||||||
participantIdentity: String(body.participantIdentity || ""),
|
participantIdentity: String(body.participantIdentity || ""),
|
||||||
|
callerPhone: callerPhone || undefined,
|
||||||
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
||||||
pathname: body.pathname ? String(body.pathname) : undefined,
|
pathname: body.pathname ? String(body.pathname) : undefined,
|
||||||
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
||||||
source: "phone-agent",
|
source: "phone-agent",
|
||||||
metadata: body.metadata ? String(body.metadata) : undefined,
|
metadata: body.metadata ? String(body.metadata) : undefined,
|
||||||
|
contactProfileId: contactContext?.contactProfile?.id,
|
||||||
|
contactDisplayName:
|
||||||
|
contactContext?.contactProfile?.displayName ||
|
||||||
|
(contactContext?.recentLead
|
||||||
|
? `${contactContext.recentLead.firstName} ${contactContext.recentLead.lastName}`.trim()
|
||||||
|
: undefined),
|
||||||
|
contactCompany:
|
||||||
|
contactContext?.contactProfile?.company ||
|
||||||
|
contactContext?.recentLead?.company ||
|
||||||
|
undefined,
|
||||||
startedAt:
|
startedAt:
|
||||||
typeof body.startedAt === "number" ? body.startedAt : undefined,
|
typeof body.startedAt === "number" ? body.startedAt : undefined,
|
||||||
recordingDisclosureAt:
|
recordingDisclosureAt:
|
||||||
|
|
@ -35,6 +64,10 @@ export async function POST(request: Request) {
|
||||||
success: true,
|
success: true,
|
||||||
sessionId: result?._id,
|
sessionId: result?._id,
|
||||||
roomName: result?.roomName,
|
roomName: result?.roomName,
|
||||||
|
callerPhone,
|
||||||
|
contactProfile: contactContext?.contactProfile || null,
|
||||||
|
recentLead: contactContext?.recentLead || null,
|
||||||
|
recentSession: contactContext?.recentSession || null,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start phone call sync:", error)
|
console.error("Failed to start phone call sync:", error)
|
||||||
|
|
|
||||||
114
convex/contactProfiles.ts
Normal file
114
convex/contactProfiles.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
import { mutation, query } from "./_generated/server"
|
||||||
|
import { v } from "convex/values"
|
||||||
|
|
||||||
|
function trimOptional(value?: string | null) {
|
||||||
|
const normalized = String(value || "").trim()
|
||||||
|
return normalized || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDisplayName(args: {
|
||||||
|
displayName?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
}) {
|
||||||
|
if (trimOptional(args.displayName)) {
|
||||||
|
return trimOptional(args.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = trimOptional(args.firstName)
|
||||||
|
const lastName = trimOptional(args.lastName)
|
||||||
|
const fallback = [firstName, lastName].filter(Boolean).join(" ").trim()
|
||||||
|
return fallback || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getByNormalizedPhone = query({
|
||||||
|
args: {
|
||||||
|
normalizedPhone: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("contactProfiles")
|
||||||
|
.withIndex("by_normalizedPhone", (q) =>
|
||||||
|
q.eq("normalizedPhone", args.normalizedPhone)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const upsertByPhone = mutation({
|
||||||
|
args: {
|
||||||
|
normalizedPhone: v.string(),
|
||||||
|
displayName: v.optional(v.string()),
|
||||||
|
firstName: v.optional(v.string()),
|
||||||
|
lastName: v.optional(v.string()),
|
||||||
|
email: v.optional(v.string()),
|
||||||
|
company: v.optional(v.string()),
|
||||||
|
lastIntent: v.optional(v.string()),
|
||||||
|
lastLeadOutcome: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("none"),
|
||||||
|
v.literal("contact"),
|
||||||
|
v.literal("requestMachine")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
lastSummaryText: v.optional(v.string()),
|
||||||
|
lastCallAt: v.optional(v.number()),
|
||||||
|
lastReminderAt: v.optional(v.number()),
|
||||||
|
reminderNotes: v.optional(v.string()),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("contactProfiles")
|
||||||
|
.withIndex("by_normalizedPhone", (q) =>
|
||||||
|
q.eq("normalizedPhone", args.normalizedPhone)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const patch = {
|
||||||
|
normalizedPhone: args.normalizedPhone,
|
||||||
|
displayName: buildDisplayName(args),
|
||||||
|
firstName: trimOptional(args.firstName),
|
||||||
|
lastName: trimOptional(args.lastName),
|
||||||
|
email: trimOptional(args.email),
|
||||||
|
company: trimOptional(args.company),
|
||||||
|
lastIntent: trimOptional(args.lastIntent),
|
||||||
|
lastLeadOutcome: args.lastLeadOutcome,
|
||||||
|
lastSummaryText: trimOptional(args.lastSummaryText),
|
||||||
|
lastCallAt: args.lastCallAt,
|
||||||
|
lastReminderAt: args.lastReminderAt,
|
||||||
|
reminderNotes: trimOptional(args.reminderNotes),
|
||||||
|
source: trimOptional(args.source),
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
displayName: patch.displayName || existing.displayName,
|
||||||
|
firstName: patch.firstName || existing.firstName,
|
||||||
|
lastName: patch.lastName || existing.lastName,
|
||||||
|
email: patch.email || existing.email,
|
||||||
|
company: patch.company || existing.company,
|
||||||
|
lastIntent: patch.lastIntent || existing.lastIntent,
|
||||||
|
lastLeadOutcome: patch.lastLeadOutcome || existing.lastLeadOutcome,
|
||||||
|
lastSummaryText: patch.lastSummaryText || existing.lastSummaryText,
|
||||||
|
lastCallAt: patch.lastCallAt || existing.lastCallAt,
|
||||||
|
lastReminderAt: patch.lastReminderAt || existing.lastReminderAt,
|
||||||
|
reminderNotes: patch.reminderNotes || existing.reminderNotes,
|
||||||
|
source: patch.source || existing.source,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return await ctx.db.get(existing._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await ctx.db.insert("contactProfiles", {
|
||||||
|
...patch,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return await ctx.db.get(id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -2,6 +2,23 @@
|
||||||
import { action, mutation } from "./_generated/server"
|
import { action, mutation } from "./_generated/server"
|
||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
|
|
||||||
|
function normalizePhone(value?: string | null) {
|
||||||
|
const digits = String(value || "").replace(/\D/g, "")
|
||||||
|
if (!digits) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return `+1${digits}`
|
||||||
|
}
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) {
|
||||||
|
return `+${digits}`
|
||||||
|
}
|
||||||
|
if (digits.length >= 11) {
|
||||||
|
return `+${digits}`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const leadSyncStatus = v.union(
|
const leadSyncStatus = v.union(
|
||||||
v.literal("pending"),
|
v.literal("pending"),
|
||||||
v.literal("sent"),
|
v.literal("sent"),
|
||||||
|
|
@ -119,12 +136,49 @@ export const createLead = mutation({
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
return await ctx.db.insert("leadSubmissions", {
|
const normalizedPhone = normalizePhone(args.phone)
|
||||||
|
const leadId = await ctx.db.insert("leadSubmissions", {
|
||||||
...args,
|
...args,
|
||||||
|
normalizedPhone,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
deliveredAt: args.status === "delivered" ? now : undefined,
|
deliveredAt: args.status === "delivered" ? now : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (normalizedPhone) {
|
||||||
|
const displayName = `${args.firstName} ${args.lastName}`.trim()
|
||||||
|
const existingProfile = await ctx.db
|
||||||
|
.query("contactProfiles")
|
||||||
|
.withIndex("by_normalizedPhone", (q) =>
|
||||||
|
q.eq("normalizedPhone", normalizedPhone)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
|
||||||
|
const patch = {
|
||||||
|
displayName: displayName || existingProfile?.displayName,
|
||||||
|
firstName: args.firstName || existingProfile?.firstName,
|
||||||
|
lastName: args.lastName || existingProfile?.lastName,
|
||||||
|
email: args.email || existingProfile?.email,
|
||||||
|
company: args.company || existingProfile?.company,
|
||||||
|
lastIntent: args.intent || existingProfile?.lastIntent,
|
||||||
|
lastLeadOutcome: args.type,
|
||||||
|
lastSummaryText: args.message || existingProfile?.lastSummaryText,
|
||||||
|
source: args.source || existingProfile?.source,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProfile) {
|
||||||
|
await ctx.db.patch(existingProfile._id, patch)
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert("contactProfiles", {
|
||||||
|
normalizedPhone,
|
||||||
|
...patch,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return leadId
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -176,6 +230,7 @@ export const ingestLead = mutation({
|
||||||
const fallbackName = splitName(args.name)
|
const fallbackName = splitName(args.name)
|
||||||
const type = mapServiceToType(args.service)
|
const type = mapServiceToType(args.service)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
const normalizedPhone = normalizePhone(args.phone)
|
||||||
const leadId = await ctx.db.insert("leadSubmissions", {
|
const leadId = await ctx.db.insert("leadSubmissions", {
|
||||||
type,
|
type,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
|
@ -184,6 +239,7 @@ export const ingestLead = mutation({
|
||||||
lastName: args.lastName || fallbackName.lastName,
|
lastName: args.lastName || fallbackName.lastName,
|
||||||
email: args.email,
|
email: args.email,
|
||||||
phone: args.phone,
|
phone: args.phone,
|
||||||
|
normalizedPhone,
|
||||||
company: args.company,
|
company: args.company,
|
||||||
intent: args.intent || args.service,
|
intent: args.intent || args.service,
|
||||||
message: args.message,
|
message: args.message,
|
||||||
|
|
@ -204,6 +260,41 @@ export const ingestLead = mutation({
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (normalizedPhone) {
|
||||||
|
const displayName = `${args.firstName || fallbackName.firstName} ${args.lastName || fallbackName.lastName}`.trim()
|
||||||
|
const existingProfile = await ctx.db
|
||||||
|
.query("contactProfiles")
|
||||||
|
.withIndex("by_normalizedPhone", (q) =>
|
||||||
|
q.eq("normalizedPhone", normalizedPhone)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
|
||||||
|
const patch = {
|
||||||
|
displayName: displayName || existingProfile?.displayName,
|
||||||
|
firstName:
|
||||||
|
args.firstName || fallbackName.firstName || existingProfile?.firstName,
|
||||||
|
lastName:
|
||||||
|
args.lastName || fallbackName.lastName || existingProfile?.lastName,
|
||||||
|
email: args.email || existingProfile?.email,
|
||||||
|
company: args.company || existingProfile?.company,
|
||||||
|
lastIntent: args.intent || args.service || existingProfile?.lastIntent,
|
||||||
|
lastLeadOutcome: type,
|
||||||
|
lastSummaryText: args.message || existingProfile?.lastSummaryText,
|
||||||
|
source: args.source || existingProfile?.source,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProfile) {
|
||||||
|
await ctx.db.patch(existingProfile._id, patch)
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert("contactProfiles", {
|
||||||
|
normalizedPhone,
|
||||||
|
...patch,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inserted: true,
|
inserted: true,
|
||||||
leadId,
|
leadId,
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@ export default defineSchema({
|
||||||
lastName: v.string(),
|
lastName: v.string(),
|
||||||
email: v.string(),
|
email: v.string(),
|
||||||
phone: v.string(),
|
phone: v.string(),
|
||||||
|
normalizedPhone: v.optional(v.string()),
|
||||||
company: v.optional(v.string()),
|
company: v.optional(v.string()),
|
||||||
intent: v.optional(v.string()),
|
intent: v.optional(v.string()),
|
||||||
message: v.optional(v.string()),
|
message: v.optional(v.string()),
|
||||||
|
|
@ -197,7 +198,34 @@ export default defineSchema({
|
||||||
.index("by_type", ["type"])
|
.index("by_type", ["type"])
|
||||||
.index("by_status", ["status"])
|
.index("by_status", ["status"])
|
||||||
.index("by_createdAt", ["createdAt"])
|
.index("by_createdAt", ["createdAt"])
|
||||||
.index("by_idempotencyKey", ["idempotencyKey"]),
|
.index("by_idempotencyKey", ["idempotencyKey"])
|
||||||
|
.index("by_normalizedPhone", ["normalizedPhone"]),
|
||||||
|
|
||||||
|
contactProfiles: defineTable({
|
||||||
|
normalizedPhone: v.string(),
|
||||||
|
displayName: v.optional(v.string()),
|
||||||
|
firstName: v.optional(v.string()),
|
||||||
|
lastName: v.optional(v.string()),
|
||||||
|
email: v.optional(v.string()),
|
||||||
|
company: v.optional(v.string()),
|
||||||
|
lastIntent: v.optional(v.string()),
|
||||||
|
lastLeadOutcome: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("none"),
|
||||||
|
v.literal("contact"),
|
||||||
|
v.literal("requestMachine")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
lastSummaryText: v.optional(v.string()),
|
||||||
|
lastCallAt: v.optional(v.number()),
|
||||||
|
lastReminderAt: v.optional(v.number()),
|
||||||
|
reminderNotes: v.optional(v.string()),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_normalizedPhone", ["normalizedPhone"])
|
||||||
|
.index("by_updatedAt", ["updatedAt"]),
|
||||||
|
|
||||||
adminUsers: defineTable({
|
adminUsers: defineTable({
|
||||||
email: v.string(),
|
email: v.string(),
|
||||||
|
|
@ -246,10 +274,14 @@ export default defineSchema({
|
||||||
voiceSessions: defineTable({
|
voiceSessions: defineTable({
|
||||||
roomName: v.string(),
|
roomName: v.string(),
|
||||||
participantIdentity: v.string(),
|
participantIdentity: v.string(),
|
||||||
|
callerPhone: v.optional(v.string()),
|
||||||
siteUrl: v.optional(v.string()),
|
siteUrl: v.optional(v.string()),
|
||||||
pathname: v.optional(v.string()),
|
pathname: v.optional(v.string()),
|
||||||
pageUrl: v.optional(v.string()),
|
pageUrl: v.optional(v.string()),
|
||||||
source: v.optional(v.string()),
|
source: v.optional(v.string()),
|
||||||
|
contactProfileId: v.optional(v.id("contactProfiles")),
|
||||||
|
contactDisplayName: v.optional(v.string()),
|
||||||
|
contactCompany: v.optional(v.string()),
|
||||||
startedAt: v.number(),
|
startedAt: v.number(),
|
||||||
endedAt: v.optional(v.number()),
|
endedAt: v.optional(v.number()),
|
||||||
callStatus: v.optional(
|
callStatus: v.optional(
|
||||||
|
|
@ -278,6 +310,28 @@ export default defineSchema({
|
||||||
),
|
),
|
||||||
notificationSentAt: v.optional(v.number()),
|
notificationSentAt: v.optional(v.number()),
|
||||||
notificationError: v.optional(v.string()),
|
notificationError: v.optional(v.string()),
|
||||||
|
reminderStatus: v.optional(
|
||||||
|
v.union(v.literal("none"), v.literal("scheduled"), v.literal("sameDay"))
|
||||||
|
),
|
||||||
|
reminderRequestedAt: v.optional(v.number()),
|
||||||
|
reminderStartAt: v.optional(v.number()),
|
||||||
|
reminderEndAt: v.optional(v.number()),
|
||||||
|
reminderCalendarEventId: v.optional(v.string()),
|
||||||
|
reminderCalendarHtmlLink: v.optional(v.string()),
|
||||||
|
reminderNote: v.optional(v.string()),
|
||||||
|
warmTransferStatus: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("none"),
|
||||||
|
v.literal("attempted"),
|
||||||
|
v.literal("connected"),
|
||||||
|
v.literal("failed"),
|
||||||
|
v.literal("fallback")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
warmTransferTarget: v.optional(v.string()),
|
||||||
|
warmTransferAttemptedAt: v.optional(v.number()),
|
||||||
|
warmTransferConnectedAt: v.optional(v.number()),
|
||||||
|
warmTransferFailureReason: v.optional(v.string()),
|
||||||
recordingDisclosureAt: v.optional(v.number()),
|
recordingDisclosureAt: v.optional(v.number()),
|
||||||
recordingStatus: v.optional(
|
recordingStatus: v.optional(
|
||||||
v.union(
|
v.union(
|
||||||
|
|
@ -297,6 +351,7 @@ export default defineSchema({
|
||||||
})
|
})
|
||||||
.index("by_roomName", ["roomName"])
|
.index("by_roomName", ["roomName"])
|
||||||
.index("by_participantIdentity", ["participantIdentity"])
|
.index("by_participantIdentity", ["participantIdentity"])
|
||||||
|
.index("by_callerPhone", ["callerPhone"])
|
||||||
.index("by_source", ["source"])
|
.index("by_source", ["source"])
|
||||||
.index("by_source_startedAt", ["source", "startedAt"])
|
.index("by_source_startedAt", ["source", "startedAt"])
|
||||||
.index("by_startedAt", ["startedAt"]),
|
.index("by_startedAt", ["startedAt"]),
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,15 @@ export const createSession = mutation({
|
||||||
args: {
|
args: {
|
||||||
roomName: v.string(),
|
roomName: v.string(),
|
||||||
participantIdentity: v.string(),
|
participantIdentity: v.string(),
|
||||||
|
callerPhone: v.optional(v.string()),
|
||||||
siteUrl: v.optional(v.string()),
|
siteUrl: v.optional(v.string()),
|
||||||
pathname: v.optional(v.string()),
|
pathname: v.optional(v.string()),
|
||||||
pageUrl: v.optional(v.string()),
|
pageUrl: v.optional(v.string()),
|
||||||
source: v.optional(v.string()),
|
source: v.optional(v.string()),
|
||||||
metadata: v.optional(v.string()),
|
metadata: v.optional(v.string()),
|
||||||
|
contactProfileId: v.optional(v.id("contactProfiles")),
|
||||||
|
contactDisplayName: v.optional(v.string()),
|
||||||
|
contactCompany: v.optional(v.string()),
|
||||||
startedAt: v.optional(v.number()),
|
startedAt: v.optional(v.number()),
|
||||||
recordingDisclosureAt: v.optional(v.number()),
|
recordingDisclosureAt: v.optional(v.number()),
|
||||||
callStatus: v.optional(
|
callStatus: v.optional(
|
||||||
|
|
@ -91,6 +95,8 @@ export const createSession = mutation({
|
||||||
leadOutcome: "none",
|
leadOutcome: "none",
|
||||||
handoffRequested: false,
|
handoffRequested: false,
|
||||||
notificationStatus: "pending",
|
notificationStatus: "pending",
|
||||||
|
reminderStatus: "none",
|
||||||
|
warmTransferStatus: "none",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
@ -101,11 +107,15 @@ export const upsertPhoneCallSession = mutation({
|
||||||
args: {
|
args: {
|
||||||
roomName: v.string(),
|
roomName: v.string(),
|
||||||
participantIdentity: v.string(),
|
participantIdentity: v.string(),
|
||||||
|
callerPhone: v.optional(v.string()),
|
||||||
siteUrl: v.optional(v.string()),
|
siteUrl: v.optional(v.string()),
|
||||||
pathname: v.optional(v.string()),
|
pathname: v.optional(v.string()),
|
||||||
pageUrl: v.optional(v.string()),
|
pageUrl: v.optional(v.string()),
|
||||||
source: v.optional(v.string()),
|
source: v.optional(v.string()),
|
||||||
metadata: v.optional(v.string()),
|
metadata: v.optional(v.string()),
|
||||||
|
contactProfileId: v.optional(v.id("contactProfiles")),
|
||||||
|
contactDisplayName: v.optional(v.string()),
|
||||||
|
contactCompany: v.optional(v.string()),
|
||||||
startedAt: v.optional(v.number()),
|
startedAt: v.optional(v.number()),
|
||||||
recordingDisclosureAt: v.optional(v.number()),
|
recordingDisclosureAt: v.optional(v.number()),
|
||||||
recordingStatus: v.optional(
|
recordingStatus: v.optional(
|
||||||
|
|
@ -128,17 +138,24 @@ export const upsertPhoneCallSession = mutation({
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await ctx.db.patch(existing._id, {
|
await ctx.db.patch(existing._id, {
|
||||||
participantIdentity: args.participantIdentity,
|
participantIdentity: args.participantIdentity,
|
||||||
|
callerPhone: args.callerPhone || existing.callerPhone,
|
||||||
siteUrl: args.siteUrl,
|
siteUrl: args.siteUrl,
|
||||||
pathname: args.pathname,
|
pathname: args.pathname,
|
||||||
pageUrl: args.pageUrl,
|
pageUrl: args.pageUrl,
|
||||||
source: args.source,
|
source: args.source,
|
||||||
metadata: args.metadata,
|
metadata: args.metadata,
|
||||||
|
contactProfileId: args.contactProfileId || existing.contactProfileId,
|
||||||
|
contactDisplayName:
|
||||||
|
args.contactDisplayName || existing.contactDisplayName,
|
||||||
|
contactCompany: args.contactCompany || existing.contactCompany,
|
||||||
startedAt: existing.startedAt || now,
|
startedAt: existing.startedAt || now,
|
||||||
recordingDisclosureAt:
|
recordingDisclosureAt:
|
||||||
args.recordingDisclosureAt ?? existing.recordingDisclosureAt,
|
args.recordingDisclosureAt ?? existing.recordingDisclosureAt,
|
||||||
recordingStatus: args.recordingStatus ?? existing.recordingStatus,
|
recordingStatus: args.recordingStatus ?? existing.recordingStatus,
|
||||||
callStatus: existing.callStatus || "started",
|
callStatus: existing.callStatus || "started",
|
||||||
notificationStatus: existing.notificationStatus || "pending",
|
notificationStatus: existing.notificationStatus || "pending",
|
||||||
|
reminderStatus: existing.reminderStatus || "none",
|
||||||
|
warmTransferStatus: existing.warmTransferStatus || "none",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
return await ctx.db.get(existing._id)
|
return await ctx.db.get(existing._id)
|
||||||
|
|
@ -147,11 +164,15 @@ export const upsertPhoneCallSession = mutation({
|
||||||
const id = await ctx.db.insert("voiceSessions", {
|
const id = await ctx.db.insert("voiceSessions", {
|
||||||
roomName: args.roomName,
|
roomName: args.roomName,
|
||||||
participantIdentity: args.participantIdentity,
|
participantIdentity: args.participantIdentity,
|
||||||
|
callerPhone: args.callerPhone,
|
||||||
siteUrl: args.siteUrl,
|
siteUrl: args.siteUrl,
|
||||||
pathname: args.pathname,
|
pathname: args.pathname,
|
||||||
pageUrl: args.pageUrl,
|
pageUrl: args.pageUrl,
|
||||||
source: args.source,
|
source: args.source,
|
||||||
metadata: args.metadata,
|
metadata: args.metadata,
|
||||||
|
contactProfileId: args.contactProfileId,
|
||||||
|
contactDisplayName: args.contactDisplayName,
|
||||||
|
contactCompany: args.contactCompany,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
recordingDisclosureAt: args.recordingDisclosureAt,
|
recordingDisclosureAt: args.recordingDisclosureAt,
|
||||||
recordingStatus: args.recordingStatus,
|
recordingStatus: args.recordingStatus,
|
||||||
|
|
@ -160,6 +181,8 @@ export const upsertPhoneCallSession = mutation({
|
||||||
leadOutcome: "none",
|
leadOutcome: "none",
|
||||||
handoffRequested: false,
|
handoffRequested: false,
|
||||||
notificationStatus: "pending",
|
notificationStatus: "pending",
|
||||||
|
reminderStatus: "none",
|
||||||
|
warmTransferStatus: "none",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
@ -213,6 +236,9 @@ export const linkPhoneCallLead = mutation({
|
||||||
args: {
|
args: {
|
||||||
sessionId: v.id("voiceSessions"),
|
sessionId: v.id("voiceSessions"),
|
||||||
linkedLeadId: v.optional(v.string()),
|
linkedLeadId: v.optional(v.string()),
|
||||||
|
contactProfileId: v.optional(v.id("contactProfiles")),
|
||||||
|
contactDisplayName: v.optional(v.string()),
|
||||||
|
contactCompany: v.optional(v.string()),
|
||||||
leadOutcome: v.optional(
|
leadOutcome: v.optional(
|
||||||
v.union(
|
v.union(
|
||||||
v.literal("none"),
|
v.literal("none"),
|
||||||
|
|
@ -222,15 +248,63 @@ export const linkPhoneCallLead = mutation({
|
||||||
),
|
),
|
||||||
handoffRequested: v.optional(v.boolean()),
|
handoffRequested: v.optional(v.boolean()),
|
||||||
handoffReason: v.optional(v.string()),
|
handoffReason: v.optional(v.string()),
|
||||||
|
reminderStatus: v.optional(
|
||||||
|
v.union(v.literal("none"), v.literal("scheduled"), v.literal("sameDay"))
|
||||||
|
),
|
||||||
|
reminderRequestedAt: v.optional(v.number()),
|
||||||
|
reminderStartAt: v.optional(v.number()),
|
||||||
|
reminderEndAt: v.optional(v.number()),
|
||||||
|
reminderCalendarEventId: v.optional(v.string()),
|
||||||
|
reminderCalendarHtmlLink: v.optional(v.string()),
|
||||||
|
reminderNote: v.optional(v.string()),
|
||||||
|
warmTransferStatus: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("none"),
|
||||||
|
v.literal("attempted"),
|
||||||
|
v.literal("connected"),
|
||||||
|
v.literal("failed"),
|
||||||
|
v.literal("fallback")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
warmTransferTarget: v.optional(v.string()),
|
||||||
|
warmTransferAttemptedAt: v.optional(v.number()),
|
||||||
|
warmTransferConnectedAt: v.optional(v.number()),
|
||||||
|
warmTransferFailureReason: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.db.patch(args.sessionId, {
|
const patch: Record<string, unknown> = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalEntries = {
|
||||||
linkedLeadId: args.linkedLeadId,
|
linkedLeadId: args.linkedLeadId,
|
||||||
|
contactProfileId: args.contactProfileId,
|
||||||
|
contactDisplayName: args.contactDisplayName,
|
||||||
|
contactCompany: args.contactCompany,
|
||||||
leadOutcome: args.leadOutcome,
|
leadOutcome: args.leadOutcome,
|
||||||
handoffRequested: args.handoffRequested,
|
handoffRequested: args.handoffRequested,
|
||||||
handoffReason: args.handoffReason,
|
handoffReason: args.handoffReason,
|
||||||
updatedAt: Date.now(),
|
reminderStatus: args.reminderStatus,
|
||||||
})
|
reminderRequestedAt: args.reminderRequestedAt,
|
||||||
|
reminderStartAt: args.reminderStartAt,
|
||||||
|
reminderEndAt: args.reminderEndAt,
|
||||||
|
reminderCalendarEventId: args.reminderCalendarEventId,
|
||||||
|
reminderCalendarHtmlLink: args.reminderCalendarHtmlLink,
|
||||||
|
reminderNote: args.reminderNote,
|
||||||
|
warmTransferStatus: args.warmTransferStatus,
|
||||||
|
warmTransferTarget: args.warmTransferTarget,
|
||||||
|
warmTransferAttemptedAt: args.warmTransferAttemptedAt,
|
||||||
|
warmTransferConnectedAt: args.warmTransferConnectedAt,
|
||||||
|
warmTransferFailureReason: args.warmTransferFailureReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(optionalEntries)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
patch[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.sessionId, patch)
|
||||||
|
|
||||||
return await ctx.db.get(args.sessionId)
|
return await ctx.db.get(args.sessionId)
|
||||||
},
|
},
|
||||||
|
|
@ -324,9 +398,13 @@ function normalizePhoneCallForAdmin(session: any) {
|
||||||
id: session._id,
|
id: session._id,
|
||||||
roomName: session.roomName,
|
roomName: session.roomName,
|
||||||
participantIdentity: session.participantIdentity,
|
participantIdentity: session.participantIdentity,
|
||||||
|
callerPhone: session.callerPhone,
|
||||||
pathname: session.pathname,
|
pathname: session.pathname,
|
||||||
pageUrl: session.pageUrl,
|
pageUrl: session.pageUrl,
|
||||||
source: session.source,
|
source: session.source,
|
||||||
|
contactProfileId: session.contactProfileId,
|
||||||
|
contactDisplayName: session.contactDisplayName,
|
||||||
|
contactCompany: session.contactCompany,
|
||||||
startedAt: session.startedAt,
|
startedAt: session.startedAt,
|
||||||
endedAt: session.endedAt,
|
endedAt: session.endedAt,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
|
@ -342,12 +420,91 @@ function normalizePhoneCallForAdmin(session: any) {
|
||||||
notificationStatus: session.notificationStatus || "pending",
|
notificationStatus: session.notificationStatus || "pending",
|
||||||
notificationSentAt: session.notificationSentAt,
|
notificationSentAt: session.notificationSentAt,
|
||||||
notificationError: session.notificationError,
|
notificationError: session.notificationError,
|
||||||
|
reminderStatus: session.reminderStatus || "none",
|
||||||
|
reminderRequestedAt: session.reminderRequestedAt,
|
||||||
|
reminderStartAt: session.reminderStartAt,
|
||||||
|
reminderEndAt: session.reminderEndAt,
|
||||||
|
reminderCalendarEventId: session.reminderCalendarEventId,
|
||||||
|
reminderCalendarHtmlLink: session.reminderCalendarHtmlLink,
|
||||||
|
reminderNote: session.reminderNote,
|
||||||
|
warmTransferStatus: session.warmTransferStatus || "none",
|
||||||
|
warmTransferTarget: session.warmTransferTarget,
|
||||||
|
warmTransferAttemptedAt: session.warmTransferAttemptedAt,
|
||||||
|
warmTransferConnectedAt: session.warmTransferConnectedAt,
|
||||||
|
warmTransferFailureReason: session.warmTransferFailureReason,
|
||||||
recordingStatus: session.recordingStatus,
|
recordingStatus: session.recordingStatus,
|
||||||
recordingUrl: session.recordingUrl,
|
recordingUrl: session.recordingUrl,
|
||||||
recordingError: session.recordingError,
|
recordingError: session.recordingError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getPhoneAgentContextByPhone = query({
|
||||||
|
args: {
|
||||||
|
normalizedPhone: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const contactProfile = await ctx.db
|
||||||
|
.query("contactProfiles")
|
||||||
|
.withIndex("by_normalizedPhone", (q) =>
|
||||||
|
q.eq("normalizedPhone", args.normalizedPhone)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
|
||||||
|
const recentSessions = await ctx.db
|
||||||
|
.query("voiceSessions")
|
||||||
|
.withIndex("by_callerPhone", (q) => q.eq("callerPhone", args.normalizedPhone))
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
recentSessions.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
|
||||||
|
const recentSession = recentSessions[0] || null
|
||||||
|
|
||||||
|
const recentLead = await ctx.db
|
||||||
|
.query("leadSubmissions")
|
||||||
|
.withIndex("by_normalizedPhone", (q) =>
|
||||||
|
q.eq("normalizedPhone", args.normalizedPhone)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
recentLead.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
|
||||||
|
const latestLead = recentLead[0] || null
|
||||||
|
|
||||||
|
return {
|
||||||
|
contactProfile: contactProfile
|
||||||
|
? {
|
||||||
|
id: contactProfile._id,
|
||||||
|
normalizedPhone: contactProfile.normalizedPhone,
|
||||||
|
displayName: contactProfile.displayName,
|
||||||
|
firstName: contactProfile.firstName,
|
||||||
|
lastName: contactProfile.lastName,
|
||||||
|
email: contactProfile.email,
|
||||||
|
company: contactProfile.company,
|
||||||
|
lastIntent: contactProfile.lastIntent,
|
||||||
|
lastLeadOutcome: contactProfile.lastLeadOutcome,
|
||||||
|
lastSummaryText: contactProfile.lastSummaryText,
|
||||||
|
lastCallAt: contactProfile.lastCallAt,
|
||||||
|
lastReminderAt: contactProfile.lastReminderAt,
|
||||||
|
reminderNotes: contactProfile.reminderNotes,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
recentSession: recentSession ? normalizePhoneCallForAdmin(recentSession) : null,
|
||||||
|
recentLead: latestLead
|
||||||
|
? {
|
||||||
|
id: latestLead._id,
|
||||||
|
type: latestLead.type,
|
||||||
|
firstName: latestLead.firstName,
|
||||||
|
lastName: latestLead.lastName,
|
||||||
|
email: latestLead.email,
|
||||||
|
phone: latestLead.phone,
|
||||||
|
company: latestLead.company,
|
||||||
|
intent: latestLead.intent,
|
||||||
|
message: latestLead.message,
|
||||||
|
createdAt: latestLead.createdAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const listAdminPhoneCalls = query({
|
export const listAdminPhoneCalls = query({
|
||||||
args: {
|
args: {
|
||||||
search: v.optional(v.string()),
|
search: v.optional(v.string()),
|
||||||
|
|
@ -381,10 +538,15 @@ export const listAdminPhoneCalls = query({
|
||||||
const haystack = [
|
const haystack = [
|
||||||
session.roomName,
|
session.roomName,
|
||||||
session.participantIdentity,
|
session.participantIdentity,
|
||||||
|
session.callerPhone,
|
||||||
|
session.contactDisplayName,
|
||||||
|
session.contactCompany,
|
||||||
session.pathname,
|
session.pathname,
|
||||||
session.linkedLeadId,
|
session.linkedLeadId,
|
||||||
session.summaryText,
|
session.summaryText,
|
||||||
session.handoffReason,
|
session.handoffReason,
|
||||||
|
session.reminderNote,
|
||||||
|
session.warmTransferFailureReason,
|
||||||
]
|
]
|
||||||
.map((value) => String(value || "").toLowerCase())
|
.map((value) => String(value || "").toLowerCase())
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|
@ -457,6 +619,9 @@ export const getAdminPhoneCallDetail = query({
|
||||||
createdAt: linkedLead.createdAt,
|
createdAt: linkedLead.createdAt,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
contactProfile: session.contactProfileId
|
||||||
|
? await ctx.db.get(session.contactProfileId)
|
||||||
|
: null,
|
||||||
turns: turns.map((turn) => ({
|
turns: turns.map((turn) => ({
|
||||||
id: turn._id,
|
id: turn._id,
|
||||||
role: turn.role,
|
role: turn.role,
|
||||||
|
|
|
||||||
420
lib/google-calendar.ts
Normal file
420
lib/google-calendar.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
const GOOGLE_CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"
|
||||||
|
const DEFAULT_TIME_ZONE = "America/Denver"
|
||||||
|
const DEFAULT_SLOT_MINUTES = 15
|
||||||
|
const DEFAULT_START_HOUR = 8
|
||||||
|
const DEFAULT_END_HOUR = 17
|
||||||
|
const OFFERABLE_WEEKDAYS = new Set([3, 4, 5])
|
||||||
|
|
||||||
|
type LocalDateTime = {
|
||||||
|
year: number
|
||||||
|
month: number
|
||||||
|
day: number
|
||||||
|
hour: number
|
||||||
|
minute: number
|
||||||
|
second: number
|
||||||
|
weekday: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type BusyInterval = {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeZone() {
|
||||||
|
return process.env.GOOGLE_CALENDAR_TIMEZONE || DEFAULT_TIME_ZONE
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlotMinutes() {
|
||||||
|
const value = Number.parseInt(
|
||||||
|
process.env.GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES || "",
|
||||||
|
10
|
||||||
|
)
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_SLOT_MINUTES
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCallbackHours() {
|
||||||
|
const startHour = Number.parseInt(
|
||||||
|
process.env.GOOGLE_CALENDAR_CALLBACK_START_HOUR || "",
|
||||||
|
10
|
||||||
|
)
|
||||||
|
const endHour = Number.parseInt(
|
||||||
|
process.env.GOOGLE_CALENDAR_CALLBACK_END_HOUR || "",
|
||||||
|
10
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHour:
|
||||||
|
Number.isFinite(startHour) && startHour >= 0 && startHour <= 23
|
||||||
|
? startHour
|
||||||
|
: DEFAULT_START_HOUR,
|
||||||
|
endHour:
|
||||||
|
Number.isFinite(endHour) && endHour >= 1 && endHour <= 24
|
||||||
|
? endHour
|
||||||
|
: DEFAULT_END_HOUR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredConfig() {
|
||||||
|
const clientId = String(process.env.GOOGLE_CALENDAR_CLIENT_ID || "").trim()
|
||||||
|
const clientSecret = String(
|
||||||
|
process.env.GOOGLE_CALENDAR_CLIENT_SECRET || ""
|
||||||
|
).trim()
|
||||||
|
const refreshToken = String(
|
||||||
|
process.env.GOOGLE_CALENDAR_REFRESH_TOKEN || ""
|
||||||
|
).trim()
|
||||||
|
const calendarId = String(process.env.GOOGLE_CALENDAR_ID || "").trim()
|
||||||
|
|
||||||
|
const missing = [
|
||||||
|
!clientId ? "GOOGLE_CALENDAR_CLIENT_ID" : null,
|
||||||
|
!clientSecret ? "GOOGLE_CALENDAR_CLIENT_SECRET" : null,
|
||||||
|
!refreshToken ? "GOOGLE_CALENDAR_REFRESH_TOKEN" : null,
|
||||||
|
!calendarId ? "GOOGLE_CALENDAR_ID" : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`${missing.join(", ")} is not configured.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
refreshToken,
|
||||||
|
calendarId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalDateTime(date: Date, timeZone = getTimeZone()): LocalDateTime {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
weekday: "short",
|
||||||
|
})
|
||||||
|
|
||||||
|
const parts = formatter.formatToParts(date)
|
||||||
|
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
||||||
|
const weekdayMap: Record<string, number> = {
|
||||||
|
Sun: 0,
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 3,
|
||||||
|
Thu: 4,
|
||||||
|
Fri: 5,
|
||||||
|
Sat: 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: Number.parseInt(values.year || "0", 10),
|
||||||
|
month: Number.parseInt(values.month || "0", 10),
|
||||||
|
day: Number.parseInt(values.day || "0", 10),
|
||||||
|
hour: Number.parseInt(values.hour || "0", 10),
|
||||||
|
minute: Number.parseInt(values.minute || "0", 10),
|
||||||
|
second: Number.parseInt(values.second || "0", 10),
|
||||||
|
weekday: weekdayMap[values.weekday || "Sun"] ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeZoneOffsetMs(date: Date, timeZone = getTimeZone()) {
|
||||||
|
const parts = getLocalDateTime(date, timeZone)
|
||||||
|
const asUtc = Date.UTC(
|
||||||
|
parts.year,
|
||||||
|
parts.month - 1,
|
||||||
|
parts.day,
|
||||||
|
parts.hour,
|
||||||
|
parts.minute,
|
||||||
|
parts.second
|
||||||
|
)
|
||||||
|
|
||||||
|
return asUtc - date.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function zonedDateTimeToUtc(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
hour: number,
|
||||||
|
minute: number,
|
||||||
|
second = 0,
|
||||||
|
timeZone = getTimeZone()
|
||||||
|
) {
|
||||||
|
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
|
||||||
|
const offset = getTimeZoneOffsetMs(utcGuess, timeZone)
|
||||||
|
return new Date(utcGuess.getTime() - offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDaysLocal(date: LocalDateTime, days: number) {
|
||||||
|
const utcMidnight = Date.UTC(date.year, date.month - 1, date.day)
|
||||||
|
const next = new Date(utcMidnight + days * 24 * 60 * 60 * 1000)
|
||||||
|
return {
|
||||||
|
year: next.getUTCFullYear(),
|
||||||
|
month: next.getUTCMonth() + 1,
|
||||||
|
day: next.getUTCDate(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundUpToSlot(date: Date, slotMinutes = getSlotMinutes()) {
|
||||||
|
const rounded = new Date(date.getTime())
|
||||||
|
rounded.setUTCSeconds(0, 0)
|
||||||
|
const intervalMs = slotMinutes * 60 * 1000
|
||||||
|
const remainder = rounded.getTime() % intervalMs
|
||||||
|
if (remainder !== 0) {
|
||||||
|
rounded.setTime(rounded.getTime() + (intervalMs - remainder))
|
||||||
|
}
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSlotLabel(startAt: Date, endAt: Date, timeZone = getTimeZone()) {
|
||||||
|
const startFormatter = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
const endFormatter = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
|
||||||
|
return `${startFormatter.format(startAt)} - ${endFormatter.format(endAt)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGoogleAccessToken() {
|
||||||
|
const config = getRequiredConfig()
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
refresh_token: config.refreshToken,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = (await response.json().catch(() => ({}))) as {
|
||||||
|
access_token?: string
|
||||||
|
error?: string
|
||||||
|
error_description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !data.access_token) {
|
||||||
|
throw new Error(
|
||||||
|
data.error_description ||
|
||||||
|
data.error ||
|
||||||
|
"Failed to authenticate with Google Calendar."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
calendarId: config.calendarId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBusyIntervals(startAt: Date, endAt: Date) {
|
||||||
|
const { accessToken, calendarId } = await getGoogleAccessToken()
|
||||||
|
const response = await fetch(`${GOOGLE_CALENDAR_API_BASE}/freeBusy`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
timeMin: startAt.toISOString(),
|
||||||
|
timeMax: endAt.toISOString(),
|
||||||
|
timeZone: getTimeZone(),
|
||||||
|
items: [{ id: calendarId }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = (await response.json().catch(() => ({}))) as {
|
||||||
|
calendars?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
busy?: Array<{ start: string; end: string }>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
error?: {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
data.error?.message || "Failed to fetch Google Calendar availability."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data.calendars?.[calendarId]?.busy || [])
|
||||||
|
.map((entry) => ({
|
||||||
|
start: new Date(entry.start).getTime(),
|
||||||
|
end: new Date(entry.end).getTime(),
|
||||||
|
}))
|
||||||
|
.filter((entry) => Number.isFinite(entry.start) && Number.isFinite(entry.end))
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapsBusyWindow(
|
||||||
|
startAt: Date,
|
||||||
|
endAt: Date,
|
||||||
|
busyIntervals: BusyInterval[]
|
||||||
|
) {
|
||||||
|
const start = startAt.getTime()
|
||||||
|
const end = endAt.getTime()
|
||||||
|
return busyIntervals.some((busy) => start < busy.end && end > busy.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFutureCallbackSlots(limit = 3) {
|
||||||
|
const timeZone = getTimeZone()
|
||||||
|
const slotMinutes = getSlotMinutes()
|
||||||
|
const { startHour, endHour } = getCallbackHours()
|
||||||
|
const now = new Date()
|
||||||
|
const nowLocal = getLocalDateTime(now, timeZone)
|
||||||
|
const tomorrow = addDaysLocal(nowLocal, 1)
|
||||||
|
const searchStart = zonedDateTimeToUtc(
|
||||||
|
tomorrow.year,
|
||||||
|
tomorrow.month,
|
||||||
|
tomorrow.day,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
timeZone
|
||||||
|
)
|
||||||
|
const searchEnd = new Date(searchStart.getTime() + 21 * 24 * 60 * 60 * 1000)
|
||||||
|
const busyIntervals = await fetchBusyIntervals(searchStart, searchEnd)
|
||||||
|
|
||||||
|
const slots: Array<{
|
||||||
|
startAt: string
|
||||||
|
endAt: string
|
||||||
|
displayLabel: string
|
||||||
|
dayLabel: string
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (let offset = 1; offset <= 21 && slots.length < limit; offset += 1) {
|
||||||
|
const day = addDaysLocal(nowLocal, offset)
|
||||||
|
const dayMarker = zonedDateTimeToUtc(
|
||||||
|
day.year,
|
||||||
|
day.month,
|
||||||
|
day.day,
|
||||||
|
12,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
timeZone
|
||||||
|
)
|
||||||
|
const weekday = getLocalDateTime(dayMarker, timeZone).weekday
|
||||||
|
|
||||||
|
if (!OFFERABLE_WEEKDAYS.has(weekday)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
let minuteOffset = 0;
|
||||||
|
minuteOffset < (endHour - startHour) * 60 && slots.length < limit;
|
||||||
|
minuteOffset += slotMinutes
|
||||||
|
) {
|
||||||
|
const hour = startHour + Math.floor(minuteOffset / 60)
|
||||||
|
const minute = minuteOffset % 60
|
||||||
|
const slotStart = zonedDateTimeToUtc(
|
||||||
|
day.year,
|
||||||
|
day.month,
|
||||||
|
day.day,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
0,
|
||||||
|
timeZone
|
||||||
|
)
|
||||||
|
const slotEnd = new Date(slotStart.getTime() + slotMinutes * 60 * 1000)
|
||||||
|
|
||||||
|
if (slotStart.getTime() <= now.getTime()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlapsBusyWindow(slotStart, slotEnd, busyIntervals)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
startAt: slotStart.toISOString(),
|
||||||
|
endAt: slotEnd.toISOString(),
|
||||||
|
displayLabel: formatSlotLabel(slotStart, slotEnd, timeZone),
|
||||||
|
dayLabel: formatSlotLabel(slotStart, slotEnd, timeZone).split(" - ")[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFollowupReminderEvent(args: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
startAt: Date
|
||||||
|
endAt: Date
|
||||||
|
}) {
|
||||||
|
const { accessToken, calendarId } = await getGoogleAccessToken()
|
||||||
|
const timeZone = getTimeZone()
|
||||||
|
const response = await fetch(
|
||||||
|
`${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
summary: args.title,
|
||||||
|
description: args.description,
|
||||||
|
start: {
|
||||||
|
dateTime: args.startAt.toISOString(),
|
||||||
|
timeZone,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: args.endAt.toISOString(),
|
||||||
|
timeZone,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = (await response.json().catch(() => ({}))) as {
|
||||||
|
id?: string
|
||||||
|
htmlLink?: string
|
||||||
|
error?: {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !data.id) {
|
||||||
|
throw new Error(
|
||||||
|
data.error?.message || "Failed to create the Google Calendar reminder."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId: data.id,
|
||||||
|
htmlLink: data.htmlLink || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSameDayReminderWindow() {
|
||||||
|
const slotMinutes = getSlotMinutes()
|
||||||
|
const startAt = roundUpToSlot(new Date(), slotMinutes)
|
||||||
|
const endAt = new Date(startAt.getTime() + slotMinutes * 60 * 1000)
|
||||||
|
|
||||||
|
return {
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,9 +15,13 @@ export type AdminPhoneCallDetail = {
|
||||||
id: string
|
id: string
|
||||||
roomName: string
|
roomName: string
|
||||||
participantIdentity: string
|
participantIdentity: string
|
||||||
|
callerPhone?: string
|
||||||
pathname?: string
|
pathname?: string
|
||||||
pageUrl?: string
|
pageUrl?: string
|
||||||
source?: string
|
source?: string
|
||||||
|
contactProfileId?: string
|
||||||
|
contactDisplayName?: string
|
||||||
|
contactCompany?: string
|
||||||
startedAt: number
|
startedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
durationMs: number | null
|
durationMs: number | null
|
||||||
|
|
@ -33,6 +37,18 @@ export type AdminPhoneCallDetail = {
|
||||||
notificationStatus: "pending" | "sent" | "failed" | "disabled"
|
notificationStatus: "pending" | "sent" | "failed" | "disabled"
|
||||||
notificationSentAt?: number
|
notificationSentAt?: number
|
||||||
notificationError?: string
|
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?:
|
recordingStatus?:
|
||||||
| "pending"
|
| "pending"
|
||||||
| "starting"
|
| "starting"
|
||||||
|
|
@ -55,6 +71,21 @@ export type AdminPhoneCallDetail = {
|
||||||
message?: string
|
message?: string
|
||||||
createdAt: number
|
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[]
|
turns: AdminPhoneCallTurn[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,20 +152,39 @@ export function buildPhoneCallSummary(
|
||||||
""
|
""
|
||||||
|
|
||||||
const callerNumber =
|
const callerNumber =
|
||||||
|
detail.call.callerPhone ||
|
||||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||||
detail.call.participantIdentity
|
detail.call.participantIdentity
|
||||||
const parts = [
|
const parts = [
|
||||||
`Caller: ${callerNumber || "Unknown caller"}.`,
|
`Caller: ${detail.call.contactDisplayName || callerNumber || "Unknown caller"}.`,
|
||||||
answeredLabel,
|
answeredLabel,
|
||||||
leadLabel,
|
leadLabel,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (detail.call.contactCompany) {
|
||||||
|
parts.push(`Company: ${detail.call.contactCompany}.`)
|
||||||
|
}
|
||||||
|
|
||||||
if (detail.call.handoffRequested) {
|
if (detail.call.handoffRequested) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`
|
`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) {
|
if (leadMessage) {
|
||||||
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`)
|
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`)
|
||||||
}
|
}
|
||||||
|
|
@ -169,8 +219,10 @@ export async function sendPhoneCallSummaryEmail(args: {
|
||||||
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`
|
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`
|
||||||
const summaryText = buildPhoneCallSummary(args.detail)
|
const summaryText = buildPhoneCallSummary(args.detail)
|
||||||
const callerNumber =
|
const callerNumber =
|
||||||
|
args.detail.call.callerPhone ||
|
||||||
normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
|
normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
|
||||||
"Unknown caller"
|
"Unknown caller"
|
||||||
|
const callerLabel = args.detail.call.contactDisplayName || callerNumber
|
||||||
const statusLabel = args.detail.call.callStatus.toUpperCase()
|
const statusLabel = args.detail.call.callStatus.toUpperCase()
|
||||||
|
|
||||||
const transcriptHtml = args.detail.turns
|
const transcriptHtml = args.detail.turns
|
||||||
|
|
@ -189,13 +241,24 @@ export async function sendPhoneCallSummaryEmail(args: {
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.6;">
|
<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>
|
<h1 style="font-size: 20px; margin-bottom: 16px;">Rocky Mountain Vending phone call summary</h1>
|
||||||
<p><strong>Caller:</strong> ${callerNumber}</p>
|
<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>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p>
|
||||||
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
|
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
|
||||||
<p><strong>Call status:</strong> ${statusLabel}</p>
|
<p><strong>Call status:</strong> ${statusLabel}</p>
|
||||||
<p><strong>Jessica answered:</strong> ${args.detail.call.answered ? "Yes" : "No"}</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>Lead outcome:</strong> ${args.detail.call.leadOutcome}</p>
|
||||||
<p><strong>Handoff requested:</strong> ${args.detail.call.handoffRequested ? "Yes" : "No"}</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 status:</strong> ${args.detail.call.recordingStatus || "Unavailable"}</p>
|
||||||
<p><strong>Recording URL:</strong> ${
|
<p><strong>Recording URL:</strong> ${
|
||||||
args.detail.call.recordingUrl
|
args.detail.call.recordingUrl
|
||||||
|
|
@ -214,7 +277,7 @@ export async function sendPhoneCallSummaryEmail(args: {
|
||||||
await sendTransactionalEmail({
|
await sendTransactionalEmail({
|
||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
to: adminEmail,
|
to: adminEmail,
|
||||||
subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`,
|
subject: `[RMV Phone] ${statusLabel} call from ${callerLabel}`,
|
||||||
html,
|
html,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
41
lib/phone-normalization.ts
Normal file
41
lib/phone-normalization.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export function normalizePhoneE164(input?: string | null) {
|
||||||
|
const digits = String(input || "").replace(/\D/g, "")
|
||||||
|
if (!digits) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return `+1${digits}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) {
|
||||||
|
return `+${digits}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length >= 11) {
|
||||||
|
return `+${digits}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitDisplayName(name?: string | null) {
|
||||||
|
const trimmed = String(name || "").trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
displayName: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
const firstName = parts.shift() || ""
|
||||||
|
const lastName = parts.join(" ")
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
displayName: [firstName, lastName].filter(Boolean).join(" "),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -143,12 +143,16 @@ function getConfiguredTenantDomains() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultDeps(): LeadSubmissionDeps {
|
function defaultDeps(): LeadSubmissionDeps {
|
||||||
|
const ghlSyncEnabled = String(process.env.ENABLE_GHL_SYNC || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() === "true"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storageConfigured: isConvexConfigured(),
|
storageConfigured: isConvexConfigured(),
|
||||||
emailConfigured: isEmailConfigured(),
|
emailConfigured: isEmailConfigured(),
|
||||||
ghlConfigured: Boolean(
|
ghlConfigured:
|
||||||
process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID
|
ghlSyncEnabled &&
|
||||||
),
|
Boolean(process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID),
|
||||||
ingest: ingestLead,
|
ingest: ingestLead,
|
||||||
updateLeadStatus: updateLeadSyncStatus,
|
updateLeadStatus: updateLeadSyncStatus,
|
||||||
sendEmail: (to, subject, html, replyTo) =>
|
sendEmail: (to, subject, html, replyTo) =>
|
||||||
|
|
|
||||||
27
lib/service-knowledge.ts
Normal file
27
lib/service-knowledge.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import {
|
||||||
|
getManualsQdrantCorpus,
|
||||||
|
searchManualsQdrantCorpus,
|
||||||
|
} from "@/lib/manuals-qdrant-corpus"
|
||||||
|
|
||||||
|
export async function searchServiceKnowledge(args: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
}) {
|
||||||
|
const corpus = await getManualsQdrantCorpus()
|
||||||
|
const results = searchManualsQdrantCorpus(corpus, args.query, {
|
||||||
|
limit: args.limit ?? 4,
|
||||||
|
profile: "public_safe",
|
||||||
|
})
|
||||||
|
|
||||||
|
return results.map((result) => ({
|
||||||
|
score: result.score,
|
||||||
|
title: result.chunk.title,
|
||||||
|
manufacturer: result.chunk.manufacturer,
|
||||||
|
model: result.chunk.model,
|
||||||
|
sourceKind: result.chunk.sourceKind,
|
||||||
|
manualType: result.chunk.manualType,
|
||||||
|
sourceFilename: result.chunk.sourceFilename,
|
||||||
|
manualUrl: result.chunk.manualUrl,
|
||||||
|
snippet: result.chunk.text.slice(0, 600).trim(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"lighthouse:ci": "lighthouse-ci autorun",
|
"lighthouse:ci": "lighthouse-ci autorun",
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
"generate:links": "node scripts/generate-internal-links.js",
|
"generate:links": "node scripts/generate-internal-links.js",
|
||||||
|
"contacts:import:ghl": "tsx scripts/import-ghl-contacts-to-contact-profiles.ts",
|
||||||
"links": "node scripts/generate-internal-links.js",
|
"links": "node scripts/generate-internal-links.js",
|
||||||
"mcp": "pnpm dlx shadcn@latest mcp",
|
"mcp": "pnpm dlx shadcn@latest mcp",
|
||||||
"seo:sitemap": "node scripts/seo-internal-link-tool.js sitemap",
|
"seo:sitemap": "node scripts/seo-internal-link-tool.js sitemap",
|
||||||
|
|
|
||||||
166
scripts/import-ghl-contacts-to-contact-profiles.ts
Normal file
166
scripts/import-ghl-contacts-to-contact-profiles.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import { resolve } from "node:path"
|
||||||
|
import { config as loadEnv } from "dotenv"
|
||||||
|
import { fetchMutation } from "convex/nextjs"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
||||||
|
loadEnv({ path: ".env.local" })
|
||||||
|
|
||||||
|
type ImportRecord = {
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
company?: string
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(value?: string | null) {
|
||||||
|
const digits = String(value || "").replace(/\D/g, "")
|
||||||
|
if (!digits) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return `+1${digits}`
|
||||||
|
}
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) {
|
||||||
|
return `+${digits}`
|
||||||
|
}
|
||||||
|
if (digits.length >= 11) {
|
||||||
|
return `+${digits}`
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCsvLine(line: string) {
|
||||||
|
const values: string[] = []
|
||||||
|
let current = ""
|
||||||
|
let inQuotes = false
|
||||||
|
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const char = line[index]
|
||||||
|
const next = line[index + 1]
|
||||||
|
|
||||||
|
if (char === '"' && inQuotes && next === '"') {
|
||||||
|
current += '"'
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "," && !inQuotes) {
|
||||||
|
values.push(current.trim())
|
||||||
|
current = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(current.trim())
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsv(content: string): ImportRecord[] {
|
||||||
|
const lines = content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = splitCsvLine(lines[0]).map((header) => header.toLowerCase())
|
||||||
|
return lines.slice(1).map((line) => {
|
||||||
|
const values = splitCsvLine(line)
|
||||||
|
const record: Record<string, string> = {}
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
record[header] = values[index] || ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstName: record.firstname || record["first name"] || record.first_name,
|
||||||
|
lastName: record.lastname || record["last name"] || record.last_name,
|
||||||
|
name: record.name || record.fullname || record["full name"],
|
||||||
|
email: record.email || record["email address"],
|
||||||
|
phone: record.phone || record["phone number"] || record.mobile,
|
||||||
|
company: record.company || record["company name"],
|
||||||
|
notes: record.notes || record.note,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(content: string): ImportRecord[] {
|
||||||
|
const value = JSON.parse(content)
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error("JSON import file must contain an array of contacts.")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(pathname: string) {
|
||||||
|
const absolutePath = resolve(pathname)
|
||||||
|
const content = await readFile(absolutePath, "utf8")
|
||||||
|
if (absolutePath.endsWith(".json")) {
|
||||||
|
return parseJson(content)
|
||||||
|
}
|
||||||
|
return parseCsv(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const inputPath = process.argv[2]
|
||||||
|
if (!inputPath) {
|
||||||
|
throw new Error("Usage: tsx scripts/import-ghl-contacts-to-contact-profiles.ts <contacts.json|contacts.csv>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await loadRecords(inputPath)
|
||||||
|
let imported = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const normalizedPhone = normalizePhone(record.phone)
|
||||||
|
if (!normalizedPhone) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
record.name?.trim() ||
|
||||||
|
[record.firstName, record.lastName].filter(Boolean).join(" ").trim()
|
||||||
|
|
||||||
|
await fetchMutation(api.contactProfiles.upsertByPhone, {
|
||||||
|
normalizedPhone,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
firstName: record.firstName?.trim() || undefined,
|
||||||
|
lastName: record.lastName?.trim() || undefined,
|
||||||
|
email: record.email?.trim() || undefined,
|
||||||
|
company: record.company?.trim() || undefined,
|
||||||
|
reminderNotes: record.notes?.trim() || undefined,
|
||||||
|
source: "ghl-import",
|
||||||
|
})
|
||||||
|
imported += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
total: records.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue