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
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
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
|
||||
LIVEKIT_URL=
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ ADMIN_EMAIL=
|
|||
ADMIN_PASSWORD=
|
||||
RESEND_API_KEY=
|
||||
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_ANON_KEY=
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
Phone Call Detail
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
{detail.call.contactDisplayName ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
detail.call.participantIdentity}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -98,6 +99,22 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
{detail.call.participantIdentity || "Unknown"}
|
||||
</p>
|
||||
</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>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Call Status
|
||||
|
|
@ -135,6 +152,22 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
</p>
|
||||
<p className="font-medium">{detail.call.notificationStatus}</p>
|
||||
</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">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Summary
|
||||
|
|
@ -157,6 +190,26 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
</p>
|
||||
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
||||
</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 ? (
|
||||
<div className="md:col-span-2">
|
||||
<Link
|
||||
|
|
@ -179,6 +232,18 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
</p>
|
||||
</div>
|
||||
) : 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>
|
||||
</Card>
|
||||
|
||||
|
|
@ -237,6 +302,23 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
lead.
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1050px] text-sm">
|
||||
<table className="w-full min-w-[1240px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<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">Lead</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 font-medium">Open</th>
|
||||
</tr>
|
||||
|
|
@ -124,7 +126,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
{data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={11}
|
||||
colSpan={13}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
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">
|
||||
<div>
|
||||
{normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) || call.participantIdentity}
|
||||
{call.contactDisplayName ||
|
||||
normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) ||
|
||||
call.participantIdentity}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{call.roomName}
|
||||
{call.contactCompany ||
|
||||
normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) ||
|
||||
call.roomName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
|
|
@ -172,6 +180,16 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
|
||||
</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">
|
||||
<span className="line-clamp-2">
|
||||
{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, {
|
||||
sessionId: body.sessionId,
|
||||
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",
|
||||
handoffRequested:
|
||||
typeof body.handoffRequested === "boolean"
|
||||
|
|
@ -22,6 +29,41 @@ export async function POST(request: Request) {
|
|||
handoffReason: body.handoffReason
|
||||
? String(body.handoffReason)
|
||||
: 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 })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { fetchMutation, 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)
|
||||
|
|
@ -11,16 +12,44 @@ export async function POST(request: Request) {
|
|||
|
||||
try {
|
||||
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(
|
||||
api.voiceSessions.upsertPhoneCallSession,
|
||||
{
|
||||
roomName: String(body.roomName || ""),
|
||||
participantIdentity: String(body.participantIdentity || ""),
|
||||
callerPhone: callerPhone || undefined,
|
||||
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
||||
pathname: body.pathname ? String(body.pathname) : undefined,
|
||||
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
||||
source: "phone-agent",
|
||||
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:
|
||||
typeof body.startedAt === "number" ? body.startedAt : undefined,
|
||||
recordingDisclosureAt:
|
||||
|
|
@ -35,6 +64,10 @@ export async function POST(request: Request) {
|
|||
success: true,
|
||||
sessionId: result?._id,
|
||||
roomName: result?.roomName,
|
||||
callerPhone,
|
||||
contactProfile: contactContext?.contactProfile || null,
|
||||
recentLead: contactContext?.recentLead || null,
|
||||
recentSession: contactContext?.recentSession || null,
|
||||
})
|
||||
} catch (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 { 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(
|
||||
v.literal("pending"),
|
||||
v.literal("sent"),
|
||||
|
|
@ -119,12 +136,49 @@ export const createLead = mutation({
|
|||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
return await ctx.db.insert("leadSubmissions", {
|
||||
const normalizedPhone = normalizePhone(args.phone)
|
||||
const leadId = await ctx.db.insert("leadSubmissions", {
|
||||
...args,
|
||||
normalizedPhone,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
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 type = mapServiceToType(args.service)
|
||||
const now = Date.now()
|
||||
const normalizedPhone = normalizePhone(args.phone)
|
||||
const leadId = await ctx.db.insert("leadSubmissions", {
|
||||
type,
|
||||
status: "pending",
|
||||
|
|
@ -184,6 +239,7 @@ export const ingestLead = mutation({
|
|||
lastName: args.lastName || fallbackName.lastName,
|
||||
email: args.email,
|
||||
phone: args.phone,
|
||||
normalizedPhone,
|
||||
company: args.company,
|
||||
intent: args.intent || args.service,
|
||||
message: args.message,
|
||||
|
|
@ -204,6 +260,41 @@ export const ingestLead = mutation({
|
|||
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 {
|
||||
inserted: true,
|
||||
leadId,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export default defineSchema({
|
|||
lastName: v.string(),
|
||||
email: v.string(),
|
||||
phone: v.string(),
|
||||
normalizedPhone: v.optional(v.string()),
|
||||
company: v.optional(v.string()),
|
||||
intent: v.optional(v.string()),
|
||||
message: v.optional(v.string()),
|
||||
|
|
@ -197,7 +198,34 @@ export default defineSchema({
|
|||
.index("by_type", ["type"])
|
||||
.index("by_status", ["status"])
|
||||
.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({
|
||||
email: v.string(),
|
||||
|
|
@ -246,10 +274,14 @@ export default defineSchema({
|
|||
voiceSessions: defineTable({
|
||||
roomName: v.string(),
|
||||
participantIdentity: v.string(),
|
||||
callerPhone: v.optional(v.string()),
|
||||
siteUrl: v.optional(v.string()),
|
||||
pathname: v.optional(v.string()),
|
||||
pageUrl: 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(),
|
||||
endedAt: v.optional(v.number()),
|
||||
callStatus: v.optional(
|
||||
|
|
@ -278,6 +310,28 @@ export default defineSchema({
|
|||
),
|
||||
notificationSentAt: v.optional(v.number()),
|
||||
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()),
|
||||
recordingStatus: v.optional(
|
||||
v.union(
|
||||
|
|
@ -297,6 +351,7 @@ export default defineSchema({
|
|||
})
|
||||
.index("by_roomName", ["roomName"])
|
||||
.index("by_participantIdentity", ["participantIdentity"])
|
||||
.index("by_callerPhone", ["callerPhone"])
|
||||
.index("by_source", ["source"])
|
||||
.index("by_source_startedAt", ["source", "startedAt"])
|
||||
.index("by_startedAt", ["startedAt"]),
|
||||
|
|
|
|||
|
|
@ -61,11 +61,15 @@ export const createSession = mutation({
|
|||
args: {
|
||||
roomName: v.string(),
|
||||
participantIdentity: v.string(),
|
||||
callerPhone: v.optional(v.string()),
|
||||
siteUrl: v.optional(v.string()),
|
||||
pathname: v.optional(v.string()),
|
||||
pageUrl: v.optional(v.string()),
|
||||
source: 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()),
|
||||
recordingDisclosureAt: v.optional(v.number()),
|
||||
callStatus: v.optional(
|
||||
|
|
@ -91,6 +95,8 @@ export const createSession = mutation({
|
|||
leadOutcome: "none",
|
||||
handoffRequested: false,
|
||||
notificationStatus: "pending",
|
||||
reminderStatus: "none",
|
||||
warmTransferStatus: "none",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
|
@ -101,11 +107,15 @@ export const upsertPhoneCallSession = mutation({
|
|||
args: {
|
||||
roomName: v.string(),
|
||||
participantIdentity: v.string(),
|
||||
callerPhone: v.optional(v.string()),
|
||||
siteUrl: v.optional(v.string()),
|
||||
pathname: v.optional(v.string()),
|
||||
pageUrl: v.optional(v.string()),
|
||||
source: 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()),
|
||||
recordingDisclosureAt: v.optional(v.number()),
|
||||
recordingStatus: v.optional(
|
||||
|
|
@ -128,17 +138,24 @@ export const upsertPhoneCallSession = mutation({
|
|||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
participantIdentity: args.participantIdentity,
|
||||
callerPhone: args.callerPhone || existing.callerPhone,
|
||||
siteUrl: args.siteUrl,
|
||||
pathname: args.pathname,
|
||||
pageUrl: args.pageUrl,
|
||||
source: args.source,
|
||||
metadata: args.metadata,
|
||||
contactProfileId: args.contactProfileId || existing.contactProfileId,
|
||||
contactDisplayName:
|
||||
args.contactDisplayName || existing.contactDisplayName,
|
||||
contactCompany: args.contactCompany || existing.contactCompany,
|
||||
startedAt: existing.startedAt || now,
|
||||
recordingDisclosureAt:
|
||||
args.recordingDisclosureAt ?? existing.recordingDisclosureAt,
|
||||
recordingStatus: args.recordingStatus ?? existing.recordingStatus,
|
||||
callStatus: existing.callStatus || "started",
|
||||
notificationStatus: existing.notificationStatus || "pending",
|
||||
reminderStatus: existing.reminderStatus || "none",
|
||||
warmTransferStatus: existing.warmTransferStatus || "none",
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return await ctx.db.get(existing._id)
|
||||
|
|
@ -147,11 +164,15 @@ export const upsertPhoneCallSession = mutation({
|
|||
const id = await ctx.db.insert("voiceSessions", {
|
||||
roomName: args.roomName,
|
||||
participantIdentity: args.participantIdentity,
|
||||
callerPhone: args.callerPhone,
|
||||
siteUrl: args.siteUrl,
|
||||
pathname: args.pathname,
|
||||
pageUrl: args.pageUrl,
|
||||
source: args.source,
|
||||
metadata: args.metadata,
|
||||
contactProfileId: args.contactProfileId,
|
||||
contactDisplayName: args.contactDisplayName,
|
||||
contactCompany: args.contactCompany,
|
||||
startedAt: now,
|
||||
recordingDisclosureAt: args.recordingDisclosureAt,
|
||||
recordingStatus: args.recordingStatus,
|
||||
|
|
@ -160,6 +181,8 @@ export const upsertPhoneCallSession = mutation({
|
|||
leadOutcome: "none",
|
||||
handoffRequested: false,
|
||||
notificationStatus: "pending",
|
||||
reminderStatus: "none",
|
||||
warmTransferStatus: "none",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
|
@ -213,6 +236,9 @@ export const linkPhoneCallLead = mutation({
|
|||
args: {
|
||||
sessionId: v.id("voiceSessions"),
|
||||
linkedLeadId: v.optional(v.string()),
|
||||
contactProfileId: v.optional(v.id("contactProfiles")),
|
||||
contactDisplayName: v.optional(v.string()),
|
||||
contactCompany: v.optional(v.string()),
|
||||
leadOutcome: v.optional(
|
||||
v.union(
|
||||
v.literal("none"),
|
||||
|
|
@ -222,15 +248,63 @@ export const linkPhoneCallLead = mutation({
|
|||
),
|
||||
handoffRequested: v.optional(v.boolean()),
|
||||
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) => {
|
||||
await ctx.db.patch(args.sessionId, {
|
||||
const patch: Record<string, unknown> = {
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
const optionalEntries = {
|
||||
linkedLeadId: args.linkedLeadId,
|
||||
contactProfileId: args.contactProfileId,
|
||||
contactDisplayName: args.contactDisplayName,
|
||||
contactCompany: args.contactCompany,
|
||||
leadOutcome: args.leadOutcome,
|
||||
handoffRequested: args.handoffRequested,
|
||||
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)
|
||||
},
|
||||
|
|
@ -324,9 +398,13 @@ function normalizePhoneCallForAdmin(session: any) {
|
|||
id: session._id,
|
||||
roomName: session.roomName,
|
||||
participantIdentity: session.participantIdentity,
|
||||
callerPhone: session.callerPhone,
|
||||
pathname: session.pathname,
|
||||
pageUrl: session.pageUrl,
|
||||
source: session.source,
|
||||
contactProfileId: session.contactProfileId,
|
||||
contactDisplayName: session.contactDisplayName,
|
||||
contactCompany: session.contactCompany,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: session.endedAt,
|
||||
durationMs,
|
||||
|
|
@ -342,12 +420,91 @@ function normalizePhoneCallForAdmin(session: any) {
|
|||
notificationStatus: session.notificationStatus || "pending",
|
||||
notificationSentAt: session.notificationSentAt,
|
||||
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,
|
||||
recordingUrl: session.recordingUrl,
|
||||
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({
|
||||
args: {
|
||||
search: v.optional(v.string()),
|
||||
|
|
@ -381,10 +538,15 @@ export const listAdminPhoneCalls = query({
|
|||
const haystack = [
|
||||
session.roomName,
|
||||
session.participantIdentity,
|
||||
session.callerPhone,
|
||||
session.contactDisplayName,
|
||||
session.contactCompany,
|
||||
session.pathname,
|
||||
session.linkedLeadId,
|
||||
session.summaryText,
|
||||
session.handoffReason,
|
||||
session.reminderNote,
|
||||
session.warmTransferFailureReason,
|
||||
]
|
||||
.map((value) => String(value || "").toLowerCase())
|
||||
.join("\n")
|
||||
|
|
@ -457,6 +619,9 @@ export const getAdminPhoneCallDetail = query({
|
|||
createdAt: linkedLead.createdAt,
|
||||
}
|
||||
: null,
|
||||
contactProfile: session.contactProfileId
|
||||
? await ctx.db.get(session.contactProfileId)
|
||||
: null,
|
||||
turns: turns.map((turn) => ({
|
||||
id: turn._id,
|
||||
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
|
||||
roomName: string
|
||||
participantIdentity: string
|
||||
callerPhone?: string
|
||||
pathname?: string
|
||||
pageUrl?: string
|
||||
source?: string
|
||||
contactProfileId?: string
|
||||
contactDisplayName?: string
|
||||
contactCompany?: string
|
||||
startedAt: number
|
||||
endedAt?: number
|
||||
durationMs: number | null
|
||||
|
|
@ -33,6 +37,18 @@ export type AdminPhoneCallDetail = {
|
|||
notificationStatus: "pending" | "sent" | "failed" | "disabled"
|
||||
notificationSentAt?: number
|
||||
notificationError?: string
|
||||
reminderStatus?: "none" | "scheduled" | "sameDay"
|
||||
reminderRequestedAt?: number
|
||||
reminderStartAt?: number
|
||||
reminderEndAt?: number
|
||||
reminderCalendarEventId?: string
|
||||
reminderCalendarHtmlLink?: string
|
||||
reminderNote?: string
|
||||
warmTransferStatus?: "none" | "attempted" | "connected" | "failed" | "fallback"
|
||||
warmTransferTarget?: string
|
||||
warmTransferAttemptedAt?: number
|
||||
warmTransferConnectedAt?: number
|
||||
warmTransferFailureReason?: string
|
||||
recordingStatus?:
|
||||
| "pending"
|
||||
| "starting"
|
||||
|
|
@ -55,6 +71,21 @@ export type AdminPhoneCallDetail = {
|
|||
message?: string
|
||||
createdAt: number
|
||||
}
|
||||
contactProfile: null | {
|
||||
_id: string
|
||||
normalizedPhone: string
|
||||
displayName?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
email?: string
|
||||
company?: string
|
||||
lastIntent?: string
|
||||
lastLeadOutcome?: "none" | "contact" | "requestMachine"
|
||||
lastSummaryText?: string
|
||||
lastCallAt?: number
|
||||
lastReminderAt?: number
|
||||
reminderNotes?: string
|
||||
}
|
||||
turns: AdminPhoneCallTurn[]
|
||||
}
|
||||
|
||||
|
|
@ -121,20 +152,39 @@ export function buildPhoneCallSummary(
|
|||
""
|
||||
|
||||
const callerNumber =
|
||||
detail.call.callerPhone ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
detail.call.participantIdentity
|
||||
const parts = [
|
||||
`Caller: ${callerNumber || "Unknown caller"}.`,
|
||||
`Caller: ${detail.call.contactDisplayName || callerNumber || "Unknown caller"}.`,
|
||||
answeredLabel,
|
||||
leadLabel,
|
||||
]
|
||||
|
||||
if (detail.call.contactCompany) {
|
||||
parts.push(`Company: ${detail.call.contactCompany}.`)
|
||||
}
|
||||
|
||||
if (detail.call.handoffRequested) {
|
||||
parts.push(
|
||||
`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.call.reminderStatus === "sameDay") {
|
||||
parts.push("A same-day follow-up reminder was created for Matt.")
|
||||
} else if (detail.call.reminderStatus === "scheduled") {
|
||||
parts.push(
|
||||
`A follow-up reminder was scheduled for ${formatPhoneCallTimestamp(detail.call.reminderStartAt)}.`
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.call.warmTransferStatus && detail.call.warmTransferStatus !== "none") {
|
||||
parts.push(
|
||||
`Warm transfer status: ${detail.call.warmTransferStatus}${detail.call.warmTransferFailureReason ? ` (${detail.call.warmTransferFailureReason})` : ""}.`
|
||||
)
|
||||
}
|
||||
|
||||
if (leadMessage) {
|
||||
parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`)
|
||||
}
|
||||
|
|
@ -169,8 +219,10 @@ export async function sendPhoneCallSummaryEmail(args: {
|
|||
const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`
|
||||
const summaryText = buildPhoneCallSummary(args.detail)
|
||||
const callerNumber =
|
||||
args.detail.call.callerPhone ||
|
||||
normalizePhoneFromIdentity(args.detail.call.participantIdentity) ||
|
||||
"Unknown caller"
|
||||
const callerLabel = args.detail.call.contactDisplayName || callerNumber
|
||||
const statusLabel = args.detail.call.callStatus.toUpperCase()
|
||||
|
||||
const transcriptHtml = args.detail.turns
|
||||
|
|
@ -189,13 +241,24 @@ export async function sendPhoneCallSummaryEmail(args: {
|
|||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; color: #111827; line-height: 1.6;">
|
||||
<h1 style="font-size: 20px; margin-bottom: 16px;">Rocky Mountain Vending phone call summary</h1>
|
||||
<p><strong>Caller:</strong> ${callerNumber}</p>
|
||||
<p><strong>Caller:</strong> ${callerLabel}</p>
|
||||
<p><strong>Caller number:</strong> ${callerNumber}</p>
|
||||
<p><strong>Company:</strong> ${args.detail.call.contactCompany || "Unknown"}</p>
|
||||
<p><strong>Started:</strong> ${formatPhoneCallTimestamp(args.detail.call.startedAt)}</p>
|
||||
<p><strong>Duration:</strong> ${formatPhoneCallDuration(args.detail.call.durationMs)}</p>
|
||||
<p><strong>Call status:</strong> ${statusLabel}</p>
|
||||
<p><strong>Jessica answered:</strong> ${args.detail.call.answered ? "Yes" : "No"}</p>
|
||||
<p><strong>Lead outcome:</strong> ${args.detail.call.leadOutcome}</p>
|
||||
<p><strong>Handoff requested:</strong> ${args.detail.call.handoffRequested ? "Yes" : "No"}</p>
|
||||
<p><strong>Reminder status:</strong> ${args.detail.call.reminderStatus || "none"}</p>
|
||||
<p><strong>Reminder time:</strong> ${formatPhoneCallTimestamp(args.detail.call.reminderStartAt)}</p>
|
||||
<p><strong>Reminder link:</strong> ${
|
||||
args.detail.call.reminderCalendarHtmlLink
|
||||
? `<a href="${args.detail.call.reminderCalendarHtmlLink}">${args.detail.call.reminderCalendarHtmlLink}</a>`
|
||||
: "No reminder link"
|
||||
}</p>
|
||||
<p><strong>Warm transfer:</strong> ${args.detail.call.warmTransferStatus || "none"}</p>
|
||||
<p><strong>Warm transfer details:</strong> ${args.detail.call.warmTransferFailureReason || "—"}</p>
|
||||
<p><strong>Recording status:</strong> ${args.detail.call.recordingStatus || "Unavailable"}</p>
|
||||
<p><strong>Recording URL:</strong> ${
|
||||
args.detail.call.recordingUrl
|
||||
|
|
@ -214,7 +277,7 @@ export async function sendPhoneCallSummaryEmail(args: {
|
|||
await sendTransactionalEmail({
|
||||
from: fromEmail,
|
||||
to: adminEmail,
|
||||
subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`,
|
||||
subject: `[RMV Phone] ${statusLabel} call from ${callerLabel}`,
|
||||
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 {
|
||||
const ghlSyncEnabled = String(process.env.ENABLE_GHL_SYNC || "")
|
||||
.trim()
|
||||
.toLowerCase() === "true"
|
||||
|
||||
return {
|
||||
storageConfigured: isConvexConfigured(),
|
||||
emailConfigured: isEmailConfigured(),
|
||||
ghlConfigured: Boolean(
|
||||
process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID
|
||||
),
|
||||
ghlConfigured:
|
||||
ghlSyncEnabled &&
|
||||
Boolean(process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID),
|
||||
ingest: ingestLead,
|
||||
updateLeadStatus: updateLeadSyncStatus,
|
||||
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",
|
||||
"analyze": "ANALYZE=true next build",
|
||||
"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",
|
||||
"mcp": "pnpm dlx shadcn@latest mcp",
|
||||
"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