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. +

+
+ + + +
+ + + + + + Call Inbox + + Search by caller number, room, summary, or linked lead ID. + + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + {data.items.length === 0 ? ( + + + + ) : ( + data.items.map((call: any) => ( + + + + + + + + + + + + + + )) + )} + +
CallerStartedDurationStatusAnsweredTranscriptRecordingLeadEmailSummaryOpen
+ No phone calls matched this filter. +
+
{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"} + + + + +
+
+ +
+

+ Showing page {data.pagination.page} of {data.pagination.totalPages} ({data.pagination.total} calls) +

+
+ {data.pagination.page > 1 ? ( + + + + ) : null} + {data.pagination.page < data.pagination.totalPages ? ( + + + + ) : 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() {

+ + +