diff --git a/.env.example b/.env.example index 3ade6c84..5a005ab1 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.env.staging.example b/.env.staging.example index aae845fd..b35938ab 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -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= diff --git a/app/admin/calls/[id]/page.tsx b/app/admin/calls/[id]/page.tsx index bdbd88fb..45c7fb99 100644 --- a/app/admin/calls/[id]/page.tsx +++ b/app/admin/calls/[id]/page.tsx @@ -50,7 +50,8 @@ export default async function AdminCallDetailPage({ params }: PageProps) { Phone Call Detail

- {normalizePhoneFromIdentity(detail.call.participantIdentity) || + {detail.call.contactDisplayName || + normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity}

@@ -98,6 +99,22 @@ export default async function AdminCallDetailPage({ params }: PageProps) { {detail.call.participantIdentity || "Unknown"}

+
+

+ Caller Phone +

+

+ {detail.call.callerPhone || + normalizePhoneFromIdentity(detail.call.participantIdentity) || + "Unknown"} +

+
+
+

+ Company +

+

{detail.call.contactCompany || "—"}

+

Call Status @@ -135,6 +152,22 @@ export default async function AdminCallDetailPage({ params }: PageProps) {

{detail.call.notificationStatus}

+
+

+ Reminder +

+

+ {detail.call.reminderStatus || "none"} +

+
+
+

+ Warm Transfer +

+

+ {detail.call.warmTransferStatus || "none"} +

+

Summary @@ -157,6 +190,26 @@ export default async function AdminCallDetailPage({ params }: PageProps) {

{detail.call.transcriptTurnCount}

+ {detail.call.reminderStartAt ? ( +
+

+ Reminder Time +

+

+ {formatPhoneCallTimestamp(detail.call.reminderStartAt)} +

+
+ ) : null} + {detail.call.warmTransferFailureReason ? ( +
+

+ Transfer Detail +

+

+ {detail.call.warmTransferFailureReason} +

+
+ ) : null} {detail.call.recordingUrl ? (
) : null} + {detail.call.reminderCalendarHtmlLink ? ( +
+ + Open reminder + + +
+ ) : null} @@ -237,6 +302,23 @@ export default async function AdminCallDetailPage({ params }: PageProps) { lead.

)} + {detail.contactProfile ? ( +
+

+ Contact Profile +

+

+ {detail.contactProfile.displayName || + [detail.contactProfile.firstName, detail.contactProfile.lastName] + .filter(Boolean) + .join(" ") || + "Known caller"} +

+

+ {detail.contactProfile.company || detail.contactProfile.email || "No company or email yet"} +

+
+ ) : null} diff --git a/app/admin/calls/page.tsx b/app/admin/calls/page.tsx index c7b89dc1..a8718589 100644 --- a/app/admin/calls/page.tsx +++ b/app/admin/calls/page.tsx @@ -104,7 +104,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
- +
@@ -116,6 +116,8 @@ export default async function AdminCallsPage({ searchParams }: PageProps) { + + @@ -124,7 +126,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) { {data.items.length === 0 ? ( + +
CallerRecording Lead EmailReminderTransfer Summary Open
No phone calls matched this filter. @@ -138,12 +140,18 @@ export default async function AdminCallsPage({ searchParams }: PageProps) { >
- {normalizePhoneFromIdentity( - call.participantIdentity - ) || call.participantIdentity} + {call.contactDisplayName || + normalizePhoneFromIdentity( + call.participantIdentity + ) || + call.participantIdentity}
- {call.roomName} + {call.contactCompany || + normalizePhoneFromIdentity( + call.participantIdentity + ) || + call.roomName}
@@ -172,6 +180,16 @@ export default async function AdminCallsPage({ searchParams }: PageProps) { {call.leadOutcome === "none" ? "—" : call.leadOutcome} {call.notificationStatus} + {call.reminderStatus === "none" + ? "—" + : call.reminderStatus} + + {call.warmTransferStatus === "none" + ? "—" + : call.warmTransferStatus} + {call.summaryText || "No summary yet"} diff --git a/app/api/internal/phone-agent/contact-lookup/route.ts b/app/api/internal/phone-agent/contact-lookup/route.ts new file mode 100644 index 00000000..e3c0f607 --- /dev/null +++ b/app/api/internal/phone-agent/contact-lookup/route.ts @@ -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 } + ) + } +} diff --git a/app/api/internal/phone-agent/followup-reminder/route.ts b/app/api/internal/phone-agent/followup-reminder/route.ts new file mode 100644 index 00000000..aca50c9c --- /dev/null +++ b/app/api/internal/phone-agent/followup-reminder/route.ts @@ -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 } + ) + } +} diff --git a/app/api/internal/phone-agent/followup-slots/route.ts b/app/api/internal/phone-agent/followup-slots/route.ts new file mode 100644 index 00000000..6dfeb2fe --- /dev/null +++ b/app/api/internal/phone-agent/followup-slots/route.ts @@ -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 } + ) + } +} diff --git a/app/api/internal/phone-agent/service-knowledge/route.ts b/app/api/internal/phone-agent/service-knowledge/route.ts new file mode 100644 index 00000000..c2f66d17 --- /dev/null +++ b/app/api/internal/phone-agent/service-knowledge/route.ts @@ -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 } + ) + } +} diff --git a/app/api/internal/phone-calls/lead-link/route.ts b/app/api/internal/phone-calls/lead-link/route.ts index d00a071d..15b2b172 100644 --- a/app/api/internal/phone-calls/lead-link/route.ts +++ b/app/api/internal/phone-calls/lead-link/route.ts @@ -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 }) diff --git a/app/api/internal/phone-calls/start/route.ts b/app/api/internal/phone-calls/start/route.ts index 14bde553..0d9939f2 100644 --- a/app/api/internal/phone-calls/start/route.ts +++ b/app/api/internal/phone-calls/start/route.ts @@ -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 = {} + 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) diff --git a/convex/contactProfiles.ts b/convex/contactProfiles.ts new file mode 100644 index 00000000..1af94c97 --- /dev/null +++ b/convex/contactProfiles.ts @@ -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) + }, +}) diff --git a/convex/leads.ts b/convex/leads.ts index 986cdc19..7ad25695 100644 --- a/convex/leads.ts +++ b/convex/leads.ts @@ -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, diff --git a/convex/schema.ts b/convex/schema.ts index bb8ada16..a9c4b46d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"]), diff --git a/convex/voiceSessions.ts b/convex/voiceSessions.ts index 8b67486d..a8417d61 100644 --- a/convex/voiceSessions.ts +++ b/convex/voiceSessions.ts @@ -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 = { + 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, diff --git a/lib/google-calendar.ts b/lib/google-calendar.ts new file mode 100644 index 00000000..19b2fc6f --- /dev/null +++ b/lib/google-calendar.ts @@ -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 = { + 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, + } +} diff --git a/lib/phone-calls.ts b/lib/phone-calls.ts index f7f58e69..7121a51a 100644 --- a/lib/phone-calls.ts +++ b/lib/phone-calls.ts @@ -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 = `

Rocky Mountain Vending phone call summary

-

Caller: ${callerNumber}

+

Caller: ${callerLabel}

+

Caller number: ${callerNumber}

+

Company: ${args.detail.call.contactCompany || "Unknown"}

Started: ${formatPhoneCallTimestamp(args.detail.call.startedAt)}

Duration: ${formatPhoneCallDuration(args.detail.call.durationMs)}

Call status: ${statusLabel}

Jessica answered: ${args.detail.call.answered ? "Yes" : "No"}

Lead outcome: ${args.detail.call.leadOutcome}

Handoff requested: ${args.detail.call.handoffRequested ? "Yes" : "No"}

+

Reminder status: ${args.detail.call.reminderStatus || "none"}

+

Reminder time: ${formatPhoneCallTimestamp(args.detail.call.reminderStartAt)}

+

Reminder link: ${ + args.detail.call.reminderCalendarHtmlLink + ? `${args.detail.call.reminderCalendarHtmlLink}` + : "No reminder link" + }

+

Warm transfer: ${args.detail.call.warmTransferStatus || "none"}

+

Warm transfer details: ${args.detail.call.warmTransferFailureReason || "—"}

Recording status: ${args.detail.call.recordingStatus || "Unavailable"}

Recording URL: ${ 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, }) diff --git a/lib/phone-normalization.ts b/lib/phone-normalization.ts new file mode 100644 index 00000000..472a3fe6 --- /dev/null +++ b/lib/phone-normalization.ts @@ -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(" "), + } +} diff --git a/lib/server/contact-submission.ts b/lib/server/contact-submission.ts index 8c97787a..5c029085 100644 --- a/lib/server/contact-submission.ts +++ b/lib/server/contact-submission.ts @@ -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) => diff --git a/lib/service-knowledge.ts b/lib/service-knowledge.ts new file mode 100644 index 00000000..208ee3d5 --- /dev/null +++ b/lib/service-knowledge.ts @@ -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(), + })) +} diff --git a/package.json b/package.json index 5f927374..1afd925b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/import-ghl-contacts-to-contact-profiles.ts b/scripts/import-ghl-contacts-to-contact-profiles.ts new file mode 100644 index 00000000..9161e603 --- /dev/null +++ b/scripts/import-ghl-contacts-to-contact-profiles.ts @@ -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 = {} + 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 ") + } + + 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) +})