feat: add local RMV tool stack for phone agent

This commit is contained in:
DMleadgen 2026-04-10 13:17:34 -06:00
parent 8fff380b24
commit bc2edc04f2
Signed by: matt
GPG key ID: C2720CF8CD701894
21 changed files with 1627 additions and 19 deletions

View file

@ -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=

View file

@ -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=

View file

@ -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>

View file

@ -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.contactDisplayName ||
normalizePhoneFromIdentity(
call.participantIdentity
) || 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"}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View file

@ -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 })

View file

@ -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
View 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)
},
})

View file

@ -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,

View file

@ -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"]),

View file

@ -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
View 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,
}
}

View file

@ -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,
})

View 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(" "),
}
}

View file

@ -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
View 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(),
}))
}

View file

@ -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",

View 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)
})