From 0d236936420439ec387f4ecbb2ab0eb00a465012 Mon Sep 17 00:00:00 2001
From: DMleadgen
Date: Wed, 1 Apr 2026 14:27:58 -0600
Subject: [PATCH] feat: add admin phone call visibility
---
.env.example | 7 +
.env.staging.example | 29 ++
app/admin/calls/[id]/page.tsx | 189 ++++++++++
app/admin/calls/page.tsx | 187 ++++++++++
app/admin/page.tsx | 9 +-
app/api/admin/calls/[id]/route.ts | 33 ++
app/api/admin/calls/route.ts | 31 ++
.../internal/phone-calls/complete/route.ts | 64 ++++
.../internal/phone-calls/lead-link/route.ts | 27 ++
app/api/internal/phone-calls/shared.ts | 51 +++
app/api/internal/phone-calls/start/route.ts | 37 ++
app/api/internal/phone-calls/turn/route.ts | 32 ++
convex/leads.ts | 159 +++++++++
convex/schema.ts | 54 ++-
convex/voiceSessions.ts | 330 +++++++++++++++++-
lib/convex.ts | 14 +
lib/phone-calls.ts | 215 ++++++++++++
lib/server/contact-submission.ts | 18 +
18 files changed, 1481 insertions(+), 5 deletions(-)
create mode 100644 .env.staging.example
create mode 100644 app/admin/calls/[id]/page.tsx
create mode 100644 app/admin/calls/page.tsx
create mode 100644 app/api/admin/calls/[id]/route.ts
create mode 100644 app/api/admin/calls/route.ts
create mode 100644 app/api/internal/phone-calls/complete/route.ts
create mode 100644 app/api/internal/phone-calls/lead-link/route.ts
create mode 100644 app/api/internal/phone-calls/shared.ts
create mode 100644 app/api/internal/phone-calls/start/route.ts
create mode 100644 app/api/internal/phone-calls/turn/route.ts
create mode 100644 lib/phone-calls.ts
diff --git a/.env.example b/.env.example
index 58f1c27b..47af96aa 100644
--- a/.env.example
+++ b/.env.example
@@ -28,8 +28,15 @@ GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG
# Optional admin/test route gating
ADMIN_UI_ENABLED=false
ADMIN_API_TOKEN=
+ADMIN_EMAIL=
+
+# Direct phone-call visibility
+PHONE_AGENT_INTERNAL_TOKEN=
+PHONE_CALL_SUMMARY_FROM_EMAIL=
+RESEND_API_KEY=
# Placeholder for a later LiveKit rollout
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
+VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app
diff --git a/.env.staging.example b/.env.staging.example
new file mode 100644
index 00000000..01fb56dc
--- /dev/null
+++ b/.env.staging.example
@@ -0,0 +1,29 @@
+NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app
+NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app
+
+CONVEX_URL=
+CONVEX_SELF_HOSTED_URL=
+CONVEX_SELF_HOSTED_ADMIN_KEY=
+CONVEX_TENANT_SLUG=rocky_mountain_vending
+CONVEX_TENANT_NAME=Rocky Mountain Vending
+
+ADMIN_UI_ENABLED=true
+ADMIN_API_TOKEN=
+ADMIN_EMAIL=
+
+PHONE_AGENT_INTERNAL_TOKEN=
+PHONE_CALL_SUMMARY_FROM_EMAIL=
+RESEND_API_KEY=
+
+USESEND_API_KEY=
+USESEND_BASE_URL=
+USESEND_FROM_EMAIL=info@rockymountainvending.com
+CONTACT_FORM_TO_EMAIL=info@rockymountainvending.com
+
+GHL_API_TOKEN=
+GHL_LOCATION_ID=
+
+LIVEKIT_URL=
+LIVEKIT_API_KEY=
+LIVEKIT_API_SECRET=
+VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app
diff --git a/app/admin/calls/[id]/page.tsx b/app/admin/calls/[id]/page.tsx
new file mode 100644
index 00000000..5626c6a0
--- /dev/null
+++ b/app/admin/calls/[id]/page.tsx
@@ -0,0 +1,189 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { fetchQuery } from "convex/nextjs";
+import { ArrowLeft, ExternalLink, Phone } from "lucide-react";
+import { api } from "@/convex/_generated/api";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ formatPhoneCallDuration,
+ formatPhoneCallTimestamp,
+ normalizePhoneFromIdentity,
+} from "@/lib/phone-calls";
+
+type PageProps = {
+ params: Promise<{
+ id: string;
+ }>;
+};
+
+export default async function AdminCallDetailPage({ params }: PageProps) {
+ const { id } = await params;
+ const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
+ callId: id,
+ });
+
+ if (!detail) {
+ notFound();
+ }
+
+ return (
+
+
+
+
+
+
+ Back to calls
+
+
Phone Call Detail
+
+ {normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity}
+
+
+
+
+
+
+
+
+
+ Call Status
+
+ Operational detail for this direct phone session.
+
+
+
+
Started
+
{formatPhoneCallTimestamp(detail.call.startedAt)}
+
+
+
Room
+
{detail.call.roomName}
+
+
+
Duration
+
{formatPhoneCallDuration(detail.call.durationMs)}
+
+
+
Participant Identity
+
{detail.call.participantIdentity || "Unknown"}
+
+
+
Call Status
+
+ {detail.call.callStatus}
+
+
+
+
Jessica Answered
+
{detail.call.answered ? "Yes" : "No"}
+
+
+
Lead Outcome
+
{detail.call.leadOutcome}
+
+
+
Email Summary
+
{detail.call.notificationStatus}
+
+
+
Summary
+
{detail.call.summaryText || "No summary available yet."}
+
+
+
Recording Status
+
{detail.call.recordingStatus || "Unavailable"}
+
+
+
Transcript Turns
+
{detail.call.transcriptTurnCount}
+
+ {detail.call.recordingUrl ? (
+
+
+ Open recording
+
+
+
+ ) : null}
+ {detail.call.notificationError ? (
+
+
Email Error
+
{detail.call.notificationError}
+
+ ) : null}
+
+
+
+
+
+ Linked Lead
+
+ {detail.linkedLead ? "Lead created from this phone call." : "No lead was created from this call."}
+
+
+
+ {detail.linkedLead ? (
+ <>
+
+
Contact
+
+ {detail.linkedLead.firstName} {detail.linkedLead.lastName}
+
+
+
+
Lead Type
+
{detail.linkedLead.type}
+
+
+
Email
+
{detail.linkedLead.email}
+
+
+
Phone
+
{detail.linkedLead.phone}
+
+
+
Message
+
{detail.linkedLead.message || "—"}
+
+ >
+ ) : (
+ Jessica handled the call, but it did not result in a submitted lead.
+ )}
+
+
+
+
+
+
+ Transcript
+ Complete mirrored transcript for this phone call.
+
+
+ {detail.turns.length === 0 ? (
+ No transcript turns were captured for this call.
+ ) : (
+ detail.turns.map((turn: any) => (
+
+
+ {turn.role}
+ {formatPhoneCallTimestamp(turn.createdAt)}
+
+
{turn.text}
+
+ ))
+ )}
+
+
+
+
+ );
+}
+
+export const metadata = {
+ title: "Phone Call Detail | Admin",
+ description: "Review a mirrored direct phone call transcript and linked lead details",
+};
diff --git a/app/admin/calls/page.tsx b/app/admin/calls/page.tsx
new file mode 100644
index 00000000..44da7c78
--- /dev/null
+++ b/app/admin/calls/page.tsx
@@ -0,0 +1,187 @@
+import Link from "next/link";
+import { fetchQuery } from "convex/nextjs";
+import { Phone, Search } from "lucide-react";
+import { api } from "@/convex/_generated/api";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ formatPhoneCallDuration,
+ formatPhoneCallTimestamp,
+ normalizePhoneFromIdentity,
+} from "@/lib/phone-calls";
+
+type PageProps = {
+ searchParams: Promise<{
+ search?: string;
+ status?: "started" | "completed" | "failed";
+ page?: string;
+ }>;
+};
+
+function getStatusVariant(status: "started" | "completed" | "failed") {
+ if (status === "failed") {
+ return "destructive" as const;
+ }
+
+ if (status === "started") {
+ return "secondary" as const;
+ }
+
+ return "default" as const;
+}
+
+export default async function AdminCallsPage({ searchParams }: PageProps) {
+ const params = await searchParams;
+ const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
+ const status = params.status;
+ const search = params.search?.trim() || undefined;
+
+ const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
+ search,
+ status,
+ page,
+ limit: 25,
+ });
+
+ return (
+
+
+
+
+
Phone Calls
+
+ Every direct LiveKit phone call mirrored into RMV admin, including partial and non-lead calls.
+
+
+
+
Back to Admin
+
+
+
+
+
+
+
+ Call Inbox
+
+ Search by caller number, room, summary, or linked lead ID.
+
+
+
+
+
+
+
+
+ Caller
+ Started
+ Duration
+ Status
+ Answered
+ Transcript
+ Recording
+ Lead
+ Email
+ Summary
+ Open
+
+
+
+ {data.items.length === 0 ? (
+
+
+ No phone calls matched this filter.
+
+
+ ) : (
+ data.items.map((call: any) => (
+
+
+ {normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}
+ {call.roomName}
+
+ {formatPhoneCallTimestamp(call.startedAt)}
+ {formatPhoneCallDuration(call.durationMs)}
+
+ {call.callStatus}
+
+ {call.answered ? "Yes" : "No"}
+
+ {call.transcriptTurnCount > 0 ? `${call.transcriptTurnCount} turns` : "No transcript"}
+
+ {call.recordingStatus || "Unavailable"}
+ {call.leadOutcome === "none" ? "—" : call.leadOutcome}
+ {call.notificationStatus}
+
+ {call.summaryText || "No summary yet"}
+
+
+
+ View
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ Showing page {data.pagination.page} of {data.pagination.totalPages} ({data.pagination.total} calls)
+
+
+ {data.pagination.page > 1 ? (
+
+ Previous
+
+ ) : null}
+ {data.pagination.page < data.pagination.totalPages ? (
+
+ Next
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
+
+export const metadata = {
+ title: "Phone Calls | Admin",
+ description: "View direct phone calls, transcript history, and lead outcomes",
+};
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index f202237a..62b1d496 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -13,7 +13,8 @@ import {
Truck,
AlertTriangle,
Settings,
- BarChart3
+ BarChart3,
+ Phone
} from 'lucide-react'
import { fetchAllProducts } from '@/lib/stripe/products'
@@ -187,6 +188,12 @@ export default async function AdminDashboard() {
+
+
+
+ Phone Calls
+
+
diff --git a/app/api/admin/calls/[id]/route.ts b/app/api/admin/calls/[id]/route.ts
new file mode 100644
index 00000000..c7e8c073
--- /dev/null
+++ b/app/api/admin/calls/[id]/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { requireAdminToken } from "@/lib/server/admin-auth";
+
+type RouteContext = {
+ params: Promise<{
+ id: string;
+ }>;
+};
+
+export async function GET(request: Request, { params }: RouteContext) {
+ const authError = requireAdminToken(request);
+ if (authError) {
+ return authError;
+ }
+
+ try {
+ const { id } = await params;
+ const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
+ callId: id,
+ });
+
+ if (!detail) {
+ return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
+ }
+
+ return NextResponse.json(detail);
+ } catch (error) {
+ console.error("Failed to load admin phone call detail:", error);
+ return NextResponse.json({ error: "Failed to load phone call detail" }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/calls/route.ts b/app/api/admin/calls/route.ts
new file mode 100644
index 00000000..eb0e7f94
--- /dev/null
+++ b/app/api/admin/calls/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { requireAdminToken } from "@/lib/server/admin-auth";
+
+export async function GET(request: Request) {
+ const authError = requireAdminToken(request);
+ if (authError) {
+ return authError;
+ }
+
+ try {
+ const { searchParams } = new URL(request.url);
+ const search = searchParams.get("search")?.trim() || undefined;
+ const status = searchParams.get("status");
+ const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1;
+ const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25;
+
+ const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
+ search,
+ status: status === "started" || status === "completed" || status === "failed" ? status : undefined,
+ page,
+ limit,
+ });
+
+ return NextResponse.json(data);
+ } catch (error) {
+ console.error("Failed to load admin phone calls:", error);
+ return NextResponse.json({ error: "Failed to load phone calls" }, { status: 500 });
+ }
+}
diff --git a/app/api/internal/phone-calls/complete/route.ts b/app/api/internal/phone-calls/complete/route.ts
new file mode 100644
index 00000000..366a3ac4
--- /dev/null
+++ b/app/api/internal/phone-calls/complete/route.ts
@@ -0,0 +1,64 @@
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
+import { buildPhoneCallSummary, sendPhoneCallSummaryEmail } from "@/lib/phone-calls";
+
+export async function POST(request: Request) {
+ const authError = await requirePhoneAgentInternalAuth(request);
+ if (authError) {
+ return authError;
+ }
+
+ try {
+ const body = await request.json();
+ const callId = String(body.sessionId || body.roomName || "");
+ if (!callId) {
+ return NextResponse.json({ error: "sessionId or roomName is required" }, { status: 400 });
+ }
+
+ const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
+ callId,
+ });
+
+ if (!detail) {
+ return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
+ }
+
+ const url = new URL(request.url);
+ const summaryText = buildPhoneCallSummary(detail);
+ const notificationResult = await sendPhoneCallSummaryEmail({
+ detail: {
+ ...detail,
+ call: {
+ ...detail.call,
+ summaryText,
+ },
+ },
+ adminUrl: url.origin,
+ });
+
+ const result = await fetchMutation(api.voiceSessions.completeSession, {
+ sessionId: detail.call.id,
+ endedAt: typeof body.endedAt === "number" ? body.endedAt : Date.now(),
+ callStatus: body.callStatus || (body.error ? "failed" : "completed"),
+ recordingStatus: body.recordingStatus,
+ recordingId: body.recordingId ? String(body.recordingId) : undefined,
+ recordingUrl: body.recordingUrl ? String(body.recordingUrl) : undefined,
+ recordingError: body.recordingError ? String(body.recordingError) : undefined,
+ summaryText,
+ notificationStatus: notificationResult.status,
+ notificationSentAt: notificationResult.status === "sent" ? Date.now() : undefined,
+ notificationError: notificationResult.error,
+ });
+
+ return NextResponse.json({
+ success: true,
+ call: result,
+ notification: notificationResult,
+ });
+ } catch (error) {
+ console.error("Failed to complete phone call sync:", error);
+ return NextResponse.json({ error: "Failed to complete phone call sync" }, { status: 500 });
+ }
+}
diff --git a/app/api/internal/phone-calls/lead-link/route.ts b/app/api/internal/phone-calls/lead-link/route.ts
new file mode 100644
index 00000000..0ac93122
--- /dev/null
+++ b/app/api/internal/phone-calls/lead-link/route.ts
@@ -0,0 +1,27 @@
+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";
+
+export async function POST(request: Request) {
+ const authError = await requirePhoneAgentInternalAuth(request);
+ if (authError) {
+ return authError;
+ }
+
+ try {
+ const body = await request.json();
+ const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
+ sessionId: body.sessionId,
+ linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
+ leadOutcome: body.leadOutcome || "none",
+ handoffRequested: typeof body.handoffRequested === "boolean" ? body.handoffRequested : undefined,
+ handoffReason: body.handoffReason ? String(body.handoffReason) : undefined,
+ });
+
+ return NextResponse.json({ success: true, call: result });
+ } catch (error) {
+ console.error("Failed to link phone call lead:", error);
+ return NextResponse.json({ error: "Failed to link phone call lead" }, { status: 500 });
+ }
+}
diff --git a/app/api/internal/phone-calls/shared.ts b/app/api/internal/phone-calls/shared.ts
new file mode 100644
index 00000000..fdc21c50
--- /dev/null
+++ b/app/api/internal/phone-calls/shared.ts
@@ -0,0 +1,51 @@
+import { timingSafeEqual } from "node:crypto";
+import { NextResponse } from "next/server";
+import { hasConvexUrl } from "@/lib/convex-config";
+
+function readBearerToken(request: Request) {
+ const authHeader = request.headers.get("authorization") || "";
+ if (!authHeader.toLowerCase().startsWith("bearer ")) {
+ return "";
+ }
+
+ return authHeader.slice("bearer ".length).trim();
+}
+
+function tokensMatch(expected: string, provided: string) {
+ const expectedBuffer = Buffer.from(expected);
+ const providedBuffer = Buffer.from(provided);
+
+ if (expectedBuffer.length !== providedBuffer.length) {
+ return false;
+ }
+
+ return timingSafeEqual(expectedBuffer, providedBuffer);
+}
+
+export function getPhoneAgentInternalToken() {
+ return String(process.env.PHONE_AGENT_INTERNAL_TOKEN || "").trim();
+}
+
+export async function requirePhoneAgentInternalAuth(request: Request) {
+ if (!hasConvexUrl()) {
+ return NextResponse.json(
+ { error: "Convex is not configured for phone call sync" },
+ { status: 503 },
+ );
+ }
+
+ const configuredToken = getPhoneAgentInternalToken();
+ if (!configuredToken) {
+ return NextResponse.json(
+ { error: "Phone call sync token is not configured" },
+ { status: 503 },
+ );
+ }
+
+ const providedToken = readBearerToken(request);
+ if (!providedToken || !tokensMatch(configuredToken, providedToken)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ return null;
+}
diff --git a/app/api/internal/phone-calls/start/route.ts b/app/api/internal/phone-calls/start/route.ts
new file mode 100644
index 00000000..742346f2
--- /dev/null
+++ b/app/api/internal/phone-calls/start/route.ts
@@ -0,0 +1,37 @@
+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";
+
+export async function POST(request: Request) {
+ const authError = await requirePhoneAgentInternalAuth(request);
+ if (authError) {
+ return authError;
+ }
+
+ try {
+ const body = await request.json();
+ const result = await fetchMutation(api.voiceSessions.upsertPhoneCallSession, {
+ roomName: String(body.roomName || ""),
+ participantIdentity: String(body.participantIdentity || ""),
+ 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,
+ startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined,
+ recordingDisclosureAt:
+ typeof body.recordingDisclosureAt === "number" ? body.recordingDisclosureAt : undefined,
+ recordingStatus: body.recordingStatus || "pending",
+ });
+
+ return NextResponse.json({
+ success: true,
+ sessionId: result?._id,
+ roomName: result?.roomName,
+ });
+ } catch (error) {
+ console.error("Failed to start phone call sync:", error);
+ return NextResponse.json({ error: "Failed to start phone call sync" }, { status: 500 });
+ }
+}
diff --git a/app/api/internal/phone-calls/turn/route.ts b/app/api/internal/phone-calls/turn/route.ts
new file mode 100644
index 00000000..02ebbbbc
--- /dev/null
+++ b/app/api/internal/phone-calls/turn/route.ts
@@ -0,0 +1,32 @@
+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";
+
+export async function POST(request: Request) {
+ const authError = await requirePhoneAgentInternalAuth(request);
+ if (authError) {
+ return authError;
+ }
+
+ try {
+ const body = await request.json();
+ await fetchMutation(api.voiceSessions.addTranscriptTurn, {
+ sessionId: body.sessionId,
+ roomName: String(body.roomName || ""),
+ participantIdentity: String(body.participantIdentity || ""),
+ role: body.role,
+ text: String(body.text || ""),
+ kind: body.kind ? String(body.kind) : undefined,
+ isFinal: typeof body.isFinal === "boolean" ? body.isFinal : undefined,
+ language: body.language ? String(body.language) : undefined,
+ source: "phone-agent",
+ createdAt: typeof body.createdAt === "number" ? body.createdAt : undefined,
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("Failed to append phone call turn:", error);
+ return NextResponse.json({ error: "Failed to append phone call turn" }, { status: 500 });
+ }
+}
diff --git a/convex/leads.ts b/convex/leads.ts
index 80ffba8f..3ee35359 100644
--- a/convex/leads.ts
+++ b/convex/leads.ts
@@ -2,6 +2,14 @@
import { action, mutation } from "./_generated/server";
import { v } from "convex/values";
+const leadSyncStatus = v.union(
+ v.literal("pending"),
+ v.literal("sent"),
+ v.literal("synced"),
+ v.literal("failed"),
+ v.literal("skipped"),
+);
+
const baseLead = {
firstName: v.string(),
lastName: v.string(),
@@ -20,6 +28,36 @@ const baseLead = {
consentSourcePage: v.optional(v.string()),
};
+function splitName(name: string) {
+ const trimmed = String(name || "").trim();
+ if (!trimmed) {
+ return { firstName: "Unknown", lastName: "Lead" };
+ }
+
+ const parts = trimmed.split(/\s+/);
+ const firstName = parts.shift() || "Unknown";
+ const lastName = parts.join(" ") || "Lead";
+ return { firstName, lastName };
+}
+
+function mapServiceToType(service: string | undefined) {
+ return String(service || "").toLowerCase() === "machine-request"
+ ? "requestMachine"
+ : "contact";
+}
+
+function deriveSubmissionStatus(usesendStatus?: string, ghlStatus?: string) {
+ if (usesendStatus === "sent" || ghlStatus === "synced") {
+ return "delivered";
+ }
+
+ if (usesendStatus === "failed" && ghlStatus === "failed") {
+ return "failed";
+ }
+
+ return "pending";
+}
+
async function sendWebhook(webhookUrl: string | undefined, payload: Record) {
if (!webhookUrl) {
return { success: false, error: "Webhook URL not configured" };
@@ -46,6 +84,7 @@ async function sendWebhook(webhookUrl: string | undefined, payload: Record {
+ const existing = await ctx.db
+ .query("leadSubmissions")
+ .withIndex("by_idempotencyKey", (q) => q.eq("idempotencyKey", args.idempotencyKey))
+ .unique();
+
+ if (existing) {
+ return {
+ inserted: false,
+ leadId: existing._id,
+ idempotencyKey: args.idempotencyKey,
+ tenantId: args.tenantSlug || args.host,
+ };
+ }
+
+ const fallbackName = splitName(args.name);
+ const type = mapServiceToType(args.service);
+ const now = Date.now();
+ const leadId = await ctx.db.insert("leadSubmissions", {
+ type,
+ status: "pending",
+ idempotencyKey: args.idempotencyKey,
+ firstName: args.firstName || fallbackName.firstName,
+ lastName: args.lastName || fallbackName.lastName,
+ email: args.email,
+ phone: args.phone,
+ company: args.company,
+ intent: args.intent || args.service,
+ message: args.message,
+ source: args.source,
+ page: args.page,
+ url: args.url,
+ employeeCount: args.employeeCount,
+ machineType: args.machineType,
+ machineCount: args.machineCount,
+ serviceTextConsent: args.serviceTextConsent,
+ marketingTextConsent: args.marketingTextConsent,
+ consentVersion: args.consentVersion,
+ consentCapturedAt: args.consentCapturedAt,
+ consentSourcePage: args.consentSourcePage,
+ usesendStatus: "pending",
+ ghlStatus: "pending",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ return {
+ inserted: true,
+ leadId,
+ idempotencyKey: args.idempotencyKey,
+ tenantId: args.tenantSlug || args.host,
+ };
+ },
+});
+
+export const updateLeadSyncStatus = mutation({
+ args: {
+ leadId: v.id("leadSubmissions"),
+ usesendStatus: v.optional(leadSyncStatus),
+ ghlStatus: v.optional(leadSyncStatus),
+ error: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const lead = await ctx.db.get(args.leadId);
+ if (!lead) {
+ throw new Error("Lead not found");
+ }
+
+ const usesendStatus = args.usesendStatus ?? lead.usesendStatus;
+ const ghlStatus = args.ghlStatus ?? lead.ghlStatus;
+ const status = deriveSubmissionStatus(usesendStatus, ghlStatus);
+ const now = Date.now();
+
+ await ctx.db.patch(args.leadId, {
+ usesendStatus,
+ ghlStatus,
+ status,
+ error: args.error,
+ deliveredAt: status === "delivered" ? lead.deliveredAt ?? now : lead.deliveredAt,
+ updatedAt: now,
+ });
+
+ return await ctx.db.get(args.leadId);
+ },
+});
+
export const submitContact = action({
args: {
...baseLead,
@@ -108,6 +263,8 @@ export const submitContact = action({
await ctx.runMutation("leads:createLead", {
type: "contact",
...args,
+ usesendStatus: "skipped",
+ ghlStatus: result.success ? "synced" : "failed",
status: result.success ? "delivered" : "failed",
error: result.error,
});
@@ -150,6 +307,8 @@ export const submitRequestMachine = action({
await ctx.runMutation("leads:createLead", {
type: "requestMachine",
...args,
+ usesendStatus: "skipped",
+ ghlStatus: result.success ? "synced" : "failed",
status: result.success ? "delivered" : "failed",
error: result.error,
});
diff --git a/convex/schema.ts b/convex/schema.ts
index 2b11b065..4038905c 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -102,6 +102,7 @@ export default defineSchema({
leadSubmissions: defineTable({
type: v.union(v.literal("contact"), v.literal("requestMachine")),
status: v.union(v.literal("pending"), v.literal("delivered"), v.literal("failed")),
+ idempotencyKey: v.optional(v.string()),
firstName: v.string(),
lastName: v.string(),
email: v.string(),
@@ -122,6 +123,24 @@ export default defineSchema({
consentSourcePage: v.optional(v.string()),
marketingConsent: v.optional(v.boolean()),
termsAgreement: v.optional(v.boolean()),
+ usesendStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("sent"),
+ v.literal("synced"),
+ v.literal("failed"),
+ v.literal("skipped"),
+ ),
+ ),
+ ghlStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("sent"),
+ v.literal("synced"),
+ v.literal("failed"),
+ v.literal("skipped"),
+ ),
+ ),
error: v.optional(v.string()),
deliveredAt: v.optional(v.number()),
createdAt: v.number(),
@@ -129,7 +148,8 @@ export default defineSchema({
})
.index("by_type", ["type"])
.index("by_status", ["status"])
- .index("by_createdAt", ["createdAt"]),
+ .index("by_createdAt", ["createdAt"])
+ .index("by_idempotencyKey", ["idempotencyKey"]),
adminUsers: defineTable({
email: v.string(),
@@ -184,6 +204,36 @@ export default defineSchema({
source: v.optional(v.string()),
startedAt: v.number(),
endedAt: v.optional(v.number()),
+ callStatus: v.optional(
+ v.union(
+ v.literal("started"),
+ v.literal("completed"),
+ v.literal("failed"),
+ ),
+ ),
+ transcriptTurnCount: v.optional(v.number()),
+ agentAnsweredAt: v.optional(v.number()),
+ linkedLeadId: v.optional(v.string()),
+ leadOutcome: v.optional(
+ v.union(
+ v.literal("none"),
+ v.literal("contact"),
+ v.literal("requestMachine"),
+ ),
+ ),
+ handoffRequested: v.optional(v.boolean()),
+ handoffReason: v.optional(v.string()),
+ summaryText: v.optional(v.string()),
+ notificationStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("sent"),
+ v.literal("failed"),
+ v.literal("disabled"),
+ ),
+ ),
+ notificationSentAt: v.optional(v.number()),
+ notificationError: v.optional(v.string()),
recordingDisclosureAt: v.optional(v.number()),
recordingStatus: v.optional(
v.union(
@@ -203,6 +253,8 @@ export default defineSchema({
})
.index("by_roomName", ["roomName"])
.index("by_participantIdentity", ["participantIdentity"])
+ .index("by_source", ["source"])
+ .index("by_source_startedAt", ["source", "startedAt"])
.index("by_startedAt", ["startedAt"]),
voiceTranscriptTurns: defineTable({
diff --git a/convex/voiceSessions.ts b/convex/voiceSessions.ts
index bd9eed73..4dc8c460 100644
--- a/convex/voiceSessions.ts
+++ b/convex/voiceSessions.ts
@@ -14,7 +14,90 @@ export const getByRoom = query({
},
});
+export const getSessionWithTurnsBySessionId = query({
+ args: {
+ sessionId: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const session = await ctx.db.get(args.sessionId as any);
+ if (!session) {
+ return null;
+ }
+
+ const turns = await ctx.db
+ .query("voiceTranscriptTurns")
+ .withIndex("by_sessionId", (q) => q.eq("sessionId", session._id))
+ .collect();
+ turns.sort((a, b) => a.createdAt - b.createdAt);
+
+ return { session, turns };
+ },
+});
+
+export const getSessionWithTurnsByRoom = query({
+ args: {
+ roomName: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const session = await ctx.db
+ .query("voiceSessions")
+ .withIndex("by_roomName", (q) => q.eq("roomName", args.roomName))
+ .unique();
+ if (!session) {
+ return null;
+ }
+
+ const turns = await ctx.db
+ .query("voiceTranscriptTurns")
+ .withIndex("by_sessionId", (q) => q.eq("sessionId", session._id))
+ .collect();
+ turns.sort((a, b) => a.createdAt - b.createdAt);
+
+ return { session, turns };
+ },
+});
+
export const createSession = mutation({
+ args: {
+ roomName: v.string(),
+ participantIdentity: 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()),
+ startedAt: v.optional(v.number()),
+ recordingDisclosureAt: v.optional(v.number()),
+ callStatus: v.optional(
+ v.union(v.literal("started"), v.literal("completed"), v.literal("failed")),
+ ),
+ recordingStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("starting"),
+ v.literal("recording"),
+ v.literal("completed"),
+ v.literal("failed"),
+ ),
+ ),
+ },
+ handler: async (ctx, args) => {
+ const now = args.startedAt ?? Date.now();
+ return await ctx.db.insert("voiceSessions", {
+ ...args,
+ startedAt: now,
+ callStatus: args.callStatus,
+ transcriptTurnCount: 0,
+ leadOutcome: "none",
+ handoffRequested: false,
+ notificationStatus: "pending",
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+export const upsertPhoneCallSession = mutation({
args: {
roomName: v.string(),
participantIdentity: v.string(),
@@ -37,12 +120,50 @@ export const createSession = mutation({
},
handler: async (ctx, args) => {
const now = args.startedAt ?? Date.now();
- return await ctx.db.insert("voiceSessions", {
- ...args,
+ const existing = await ctx.db
+ .query("voiceSessions")
+ .withIndex("by_roomName", (q) => q.eq("roomName", args.roomName))
+ .unique();
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ participantIdentity: args.participantIdentity,
+ siteUrl: args.siteUrl,
+ pathname: args.pathname,
+ pageUrl: args.pageUrl,
+ source: args.source,
+ metadata: args.metadata,
+ startedAt: existing.startedAt || now,
+ recordingDisclosureAt: args.recordingDisclosureAt ?? existing.recordingDisclosureAt,
+ recordingStatus: args.recordingStatus ?? existing.recordingStatus,
+ callStatus: existing.callStatus || "started",
+ notificationStatus: existing.notificationStatus || "pending",
+ updatedAt: Date.now(),
+ });
+ return await ctx.db.get(existing._id);
+ }
+
+ const id = await ctx.db.insert("voiceSessions", {
+ roomName: args.roomName,
+ participantIdentity: args.participantIdentity,
+ siteUrl: args.siteUrl,
+ pathname: args.pathname,
+ pageUrl: args.pageUrl,
+ source: args.source,
+ metadata: args.metadata,
startedAt: now,
+ recordingDisclosureAt: args.recordingDisclosureAt,
+ recordingStatus: args.recordingStatus,
+ callStatus: "started",
+ transcriptTurnCount: 0,
+ leadOutcome: "none",
+ handoffRequested: false,
+ notificationStatus: "pending",
createdAt: now,
updatedAt: now,
});
+
+ return await ctx.db.get(id);
},
});
@@ -61,11 +182,46 @@ export const addTranscriptTurn = mutation({
},
handler: async (ctx, args) => {
const createdAt = args.createdAt ?? Date.now();
- return await ctx.db.insert("voiceTranscriptTurns", {
+ const turnId = await ctx.db.insert("voiceTranscriptTurns", {
...args,
text: args.text.trim(),
createdAt,
});
+
+ const session = await ctx.db.get(args.sessionId);
+ if (session) {
+ await ctx.db.patch(args.sessionId, {
+ transcriptTurnCount: (session.transcriptTurnCount ?? 0) + 1,
+ agentAnsweredAt:
+ args.role === "assistant" && !session.agentAnsweredAt ? createdAt : session.agentAnsweredAt,
+ updatedAt: Date.now(),
+ });
+ }
+
+ return turnId;
+ },
+});
+
+export const linkPhoneCallLead = mutation({
+ args: {
+ sessionId: v.id("voiceSessions"),
+ linkedLeadId: v.optional(v.string()),
+ leadOutcome: v.optional(
+ v.union(v.literal("none"), v.literal("contact"), v.literal("requestMachine")),
+ ),
+ handoffRequested: v.optional(v.boolean()),
+ handoffReason: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.sessionId, {
+ linkedLeadId: args.linkedLeadId,
+ leadOutcome: args.leadOutcome,
+ handoffRequested: args.handoffRequested,
+ handoffReason: args.handoffReason,
+ updatedAt: Date.now(),
+ });
+
+ return await ctx.db.get(args.sessionId);
},
});
@@ -101,6 +257,9 @@ export const completeSession = mutation({
args: {
sessionId: v.id("voiceSessions"),
endedAt: v.optional(v.number()),
+ callStatus: v.optional(
+ v.union(v.literal("started"), v.literal("completed"), v.literal("failed")),
+ ),
recordingStatus: v.optional(
v.union(
v.literal("pending"),
@@ -113,17 +272,182 @@ export const completeSession = mutation({
recordingId: v.optional(v.string()),
recordingUrl: v.optional(v.string()),
recordingError: v.optional(v.string()),
+ summaryText: v.optional(v.string()),
+ notificationStatus: v.optional(
+ v.union(
+ v.literal("pending"),
+ v.literal("sent"),
+ v.literal("failed"),
+ v.literal("disabled"),
+ ),
+ ),
+ notificationSentAt: v.optional(v.number()),
+ notificationError: v.optional(v.string()),
},
handler: async (ctx, args) => {
const endedAt = args.endedAt ?? Date.now();
await ctx.db.patch(args.sessionId, {
endedAt,
+ callStatus: args.callStatus,
recordingStatus: args.recordingStatus,
recordingId: args.recordingId,
recordingUrl: args.recordingUrl,
recordingError: args.recordingError,
+ summaryText: args.summaryText,
+ notificationStatus: args.notificationStatus,
+ notificationSentAt: args.notificationSentAt,
+ notificationError: args.notificationError,
updatedAt: endedAt,
});
return await ctx.db.get(args.sessionId);
},
});
+
+function normalizePhoneCallForAdmin(session: any) {
+ const durationMs =
+ typeof session.endedAt === "number" && typeof session.startedAt === "number"
+ ? Math.max(0, session.endedAt - session.startedAt)
+ : null;
+
+ return {
+ id: session._id,
+ roomName: session.roomName,
+ participantIdentity: session.participantIdentity,
+ pathname: session.pathname,
+ pageUrl: session.pageUrl,
+ source: session.source,
+ startedAt: session.startedAt,
+ endedAt: session.endedAt,
+ durationMs,
+ callStatus: session.callStatus || "started",
+ transcriptTurnCount: session.transcriptTurnCount ?? 0,
+ answered: Boolean(session.agentAnsweredAt),
+ agentAnsweredAt: session.agentAnsweredAt,
+ linkedLeadId: session.linkedLeadId,
+ leadOutcome: session.leadOutcome || "none",
+ handoffRequested: Boolean(session.handoffRequested),
+ handoffReason: session.handoffReason,
+ summaryText: session.summaryText,
+ notificationStatus: session.notificationStatus || "pending",
+ notificationSentAt: session.notificationSentAt,
+ notificationError: session.notificationError,
+ recordingStatus: session.recordingStatus,
+ recordingUrl: session.recordingUrl,
+ recordingError: session.recordingError,
+ };
+}
+
+export const listAdminPhoneCalls = query({
+ args: {
+ search: v.optional(v.string()),
+ status: v.optional(v.union(v.literal("started"), v.literal("completed"), v.literal("failed"))),
+ page: v.optional(v.number()),
+ limit: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const page = Math.max(1, args.page ?? 1);
+ const limit = Math.min(100, Math.max(1, args.limit ?? 25));
+ const search = String(args.search || "").trim().toLowerCase();
+
+ const sessions = await ctx.db
+ .query("voiceSessions")
+ .withIndex("by_source_startedAt", (q) => q.eq("source", "phone-agent"))
+ .collect();
+
+ const filtered = sessions.filter((session) => {
+ if (args.status && (session.callStatus || "started") !== args.status) {
+ return false;
+ }
+
+ if (!search) {
+ return true;
+ }
+
+ const haystack = [
+ session.roomName,
+ session.participantIdentity,
+ session.pathname,
+ session.linkedLeadId,
+ session.summaryText,
+ session.handoffReason,
+ ]
+ .map((value) => String(value || "").toLowerCase())
+ .join("\n");
+
+ return haystack.includes(search);
+ });
+
+ filtered.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
+
+ const total = filtered.length;
+ const items = filtered
+ .slice((page - 1) * limit, page * limit)
+ .map(normalizePhoneCallForAdmin);
+
+ return {
+ items,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.max(1, Math.ceil(total / limit)),
+ },
+ };
+ },
+});
+
+export const getAdminPhoneCallDetail = query({
+ args: {
+ callId: v.string(),
+ },
+ handler: async (ctx, args) => {
+ let session = await ctx.db.get(args.callId as any);
+
+ if (!session) {
+ session = await ctx.db
+ .query("voiceSessions")
+ .withIndex("by_roomName", (q) => q.eq("roomName", args.callId))
+ .unique();
+ }
+
+ if (!session || session.source !== "phone-agent") {
+ return null;
+ }
+
+ const turns = await ctx.db
+ .query("voiceTranscriptTurns")
+ .withIndex("by_sessionId", (q) => q.eq("sessionId", session._id))
+ .collect();
+
+ turns.sort((a, b) => a.createdAt - b.createdAt);
+
+ const linkedLead = session.linkedLeadId ? await ctx.db.get(session.linkedLeadId as any) : null;
+
+ return {
+ call: normalizePhoneCallForAdmin(session),
+ linkedLead: linkedLead
+ ? {
+ id: linkedLead._id,
+ type: linkedLead.type,
+ status: linkedLead.status,
+ firstName: linkedLead.firstName,
+ lastName: linkedLead.lastName,
+ email: linkedLead.email,
+ phone: linkedLead.phone,
+ company: linkedLead.company,
+ intent: linkedLead.intent,
+ message: linkedLead.message,
+ createdAt: linkedLead.createdAt,
+ }
+ : null,
+ turns: turns.map((turn) => ({
+ id: turn._id,
+ role: turn.role,
+ text: turn.text,
+ source: turn.source,
+ kind: turn.kind,
+ createdAt: turn.createdAt,
+ })),
+ };
+ },
+});
diff --git a/lib/convex.ts b/lib/convex.ts
index 1048edd5..80e520cf 100644
--- a/lib/convex.ts
+++ b/lib/convex.ts
@@ -8,10 +8,24 @@ export type IngestLeadInput = {
source?: string;
idempotencyKey: string;
name: string;
+ firstName?: string;
+ lastName?: string;
email: string;
+ phone: string;
company?: string;
service?: string;
+ intent?: string;
+ page?: string;
+ url?: string;
message: string;
+ employeeCount?: string;
+ machineType?: string;
+ machineCount?: string;
+ serviceTextConsent?: boolean;
+ marketingTextConsent?: boolean;
+ consentVersion?: string;
+ consentCapturedAt?: string;
+ consentSourcePage?: string;
};
export type IngestLeadResult = {
diff --git a/lib/phone-calls.ts b/lib/phone-calls.ts
new file mode 100644
index 00000000..36b2515a
--- /dev/null
+++ b/lib/phone-calls.ts
@@ -0,0 +1,215 @@
+import { businessConfig } from "@/lib/seo-config";
+
+export type AdminPhoneCallTurn = {
+ id: string;
+ role: string;
+ text: string;
+ source?: string;
+ kind?: string;
+ createdAt: number;
+};
+
+export type AdminPhoneCallDetail = {
+ call: {
+ id: string;
+ roomName: string;
+ participantIdentity: string;
+ pathname?: string;
+ pageUrl?: string;
+ source?: string;
+ startedAt: number;
+ endedAt?: number;
+ durationMs: number | null;
+ callStatus: "started" | "completed" | "failed";
+ transcriptTurnCount: number;
+ answered: boolean;
+ agentAnsweredAt?: number;
+ linkedLeadId?: string;
+ leadOutcome: "none" | "contact" | "requestMachine";
+ handoffRequested: boolean;
+ handoffReason?: string;
+ summaryText?: string;
+ notificationStatus: "pending" | "sent" | "failed" | "disabled";
+ notificationSentAt?: number;
+ notificationError?: string;
+ recordingStatus?: "pending" | "starting" | "recording" | "completed" | "failed";
+ recordingUrl?: string;
+ recordingError?: string;
+ };
+ linkedLead: null | {
+ id: string;
+ type: "contact" | "requestMachine";
+ status: "pending" | "delivered" | "failed";
+ firstName: string;
+ lastName: string;
+ email: string;
+ phone: string;
+ company?: string;
+ intent?: string;
+ message?: string;
+ createdAt: number;
+ };
+ turns: AdminPhoneCallTurn[];
+};
+
+export function formatPhoneCallTimestamp(timestamp?: number) {
+ if (!timestamp) {
+ return "—";
+ }
+
+ return new Date(timestamp).toLocaleString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export function formatPhoneCallDuration(durationMs: number | null | undefined) {
+ if (typeof durationMs !== "number" || durationMs <= 0) {
+ return "—";
+ }
+
+ const totalSeconds = Math.round(durationMs / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
+}
+
+export function normalizePhoneFromIdentity(identity?: string) {
+ const digits = String(identity || "").replace(/\D/g, "");
+ if (!digits) {
+ return "";
+ }
+ if (digits.length === 10) {
+ return `+1${digits}`;
+ }
+ if (digits.length === 11 && digits.startsWith("1")) {
+ return `+${digits}`;
+ }
+ return `+${digits}`;
+}
+
+export function buildPhoneCallSummary(detail: Pick) {
+ const answeredLabel = detail.call.answered ? "Jessica answered the call." : "Jessica did not fully answer the call.";
+ const leadLabel =
+ detail.call.leadOutcome === "none"
+ ? "No lead was submitted."
+ : detail.call.leadOutcome === "requestMachine"
+ ? "A machine request lead was submitted."
+ : "A contact lead was submitted.";
+
+ const lastMeaningfulUserTurn = [...detail.turns]
+ .reverse()
+ .find((turn) => turn.role === "user" && turn.text.trim());
+
+ const leadMessage =
+ detail.linkedLead?.message?.trim() ||
+ detail.linkedLead?.intent?.trim() ||
+ lastMeaningfulUserTurn?.text.trim() ||
+ "";
+
+ const callerNumber = normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity;
+ const parts = [
+ `Caller: ${callerNumber || "Unknown caller"}.`,
+ answeredLabel,
+ leadLabel,
+ ];
+
+ if (detail.call.handoffRequested) {
+ parts.push(`Human escalation requested${detail.call.handoffReason ? `: ${detail.call.handoffReason}.` : "."}`);
+ }
+
+ if (leadMessage) {
+ parts.push(`Topic: ${leadMessage.replace(/\s+/g, " ").slice(0, 220)}.`);
+ }
+
+ return parts.join(" ");
+}
+
+export async function sendPhoneCallSummaryEmail(args: {
+ detail: AdminPhoneCallDetail;
+ adminUrl: string;
+}) {
+ const resendApiKey = String(process.env.RESEND_API_KEY || "").trim();
+ const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
+ const fromEmail = String(process.env.PHONE_CALL_SUMMARY_FROM_EMAIL || "").trim();
+
+ if (!adminEmail || !resendApiKey || !fromEmail) {
+ const missing = [
+ !adminEmail ? "ADMIN_EMAIL" : null,
+ !resendApiKey ? "RESEND_API_KEY" : null,
+ !fromEmail ? "PHONE_CALL_SUMMARY_FROM_EMAIL" : null,
+ ].filter(Boolean);
+
+ return {
+ status: "disabled" as const,
+ error: `${missing.join(", ")} is not configured.`,
+ };
+ }
+
+ const callUrl = `${args.adminUrl.replace(/\/$/, "")}/admin/calls/${args.detail.call.id}`;
+ const summaryText = buildPhoneCallSummary(args.detail);
+ const callerNumber = normalizePhoneFromIdentity(args.detail.call.participantIdentity) || "Unknown caller";
+ const statusLabel = args.detail.call.callStatus.toUpperCase();
+
+ const text = [
+ `Rocky Mountain Vending phone call summary`,
+ ``,
+ `Caller: ${callerNumber}`,
+ `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"}`,
+ `Recording status: ${args.detail.call.recordingStatus || "Unavailable"}`,
+ args.detail.call.recordingUrl ? `Recording URL: ${args.detail.call.recordingUrl}` : `Recording URL: Not available in RMV admin`,
+ `Summary: ${summaryText}`,
+ `Admin call detail: ${callUrl}`,
+ args.detail.linkedLead?.id ? `Linked lead: ${args.detail.linkedLead.id}` : `Linked lead: None`,
+ ``,
+ `Recent transcript:`,
+ ...args.detail.turns.slice(-6).map((turn) => `${turn.role}: ${turn.text}`),
+ ].join("\n");
+
+ const response = await fetch("https://api.resend.com/emails", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${resendApiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ from: fromEmail,
+ to: [adminEmail],
+ subject: `[RMV Phone] ${statusLabel} call from ${callerNumber}`,
+ text,
+ }),
+ });
+
+ const body = (await response.json().catch(() => ({}))) as { message?: string; error?: unknown };
+
+ if (!response.ok) {
+ const errorText =
+ typeof body?.message === "string"
+ ? body.message
+ : typeof body?.error === "string"
+ ? body.error
+ : `Resend request failed with status ${response.status}.`;
+
+ return {
+ status: "failed" as const,
+ error: errorText,
+ };
+ }
+
+ return {
+ status: "sent" as const,
+ error: undefined,
+ };
+}
+
+export function buildFallbackPhoneCallUrl(callId: string) {
+ return `${businessConfig.website.replace(/\/$/, "")}/admin/calls/${callId}`;
+}
diff --git a/lib/server/contact-submission.ts b/lib/server/contact-submission.ts
index 59e364ea..34a3171f 100644
--- a/lib/server/contact-submission.ts
+++ b/lib/server/contact-submission.ts
@@ -54,6 +54,7 @@ export type LeadPayload = ContactLeadPayload | RequestMachineLeadPayload;
export type LeadSubmissionResponse = {
success: boolean;
message: string;
+ leadId?: string;
deduped?: boolean;
leadStored?: boolean;
storageConfigured?: boolean;
@@ -496,10 +497,24 @@ export async function processLeadSubmission(
: "rocky_contact_form",
idempotencyKey,
name: fullName,
+ firstName: payload.firstName,
+ lastName: payload.lastName,
email: payload.email,
+ phone: payload.phone,
company: payload.company || undefined,
service: payload.kind === "request-machine" ? "machine-request" : "contact",
+ intent: payload.intent,
+ page: payload.page,
+ url: payload.url,
message: leadMessage,
+ employeeCount: payload.kind === "request-machine" ? payload.employeeCount : undefined,
+ machineType: payload.kind === "request-machine" ? payload.machineType : undefined,
+ machineCount: payload.kind === "request-machine" ? payload.machineCount : undefined,
+ serviceTextConsent: payload.serviceTextConsent,
+ marketingTextConsent: payload.marketingTextConsent,
+ consentVersion: payload.consentVersion,
+ consentCapturedAt: payload.consentCapturedAt,
+ consentSourcePage: payload.consentSourcePage,
});
leadId = ingestResult.leadId;
@@ -515,6 +530,7 @@ export async function processLeadSubmission(
storageConfigured: deps.storageConfigured,
storageStatus,
idempotencyKey,
+ leadId,
deliveredVia: ["convex"],
sync: { usesendStatus, ghlStatus },
},
@@ -606,6 +622,7 @@ export async function processLeadSubmission(
storageConfigured: deps.storageConfigured,
storageStatus,
idempotencyKey,
+ leadId,
sync: { usesendStatus, ghlStatus },
warnings,
},
@@ -624,6 +641,7 @@ export async function processLeadSubmission(
storageConfigured: deps.storageConfigured,
storageStatus,
idempotencyKey,
+ leadId,
deliveredVia,
sync: { usesendStatus, ghlStatus },
...(warnings.length > 0 ? { warnings } : {}),