feat: add admin phone call visibility
This commit is contained in:
parent
3ed66cc715
commit
0d23693642
18 changed files with 1481 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
29
.env.staging.example
Normal file
29
.env.staging.example
Normal file
|
|
@ -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
|
||||
189
app/admin/calls/[id]/page.tsx
Normal file
189
app/admin/calls/[id]/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Link href="/admin/calls" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to calls
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Call Detail</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5" />
|
||||
Call Status
|
||||
</CardTitle>
|
||||
<CardDescription>Operational detail for this direct phone session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Started</p>
|
||||
<p className="font-medium">{formatPhoneCallTimestamp(detail.call.startedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Room</p>
|
||||
<p className="font-medium break-all">{detail.call.roomName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Duration</p>
|
||||
<p className="font-medium">{formatPhoneCallDuration(detail.call.durationMs)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Participant Identity</p>
|
||||
<p className="font-medium break-all">{detail.call.participantIdentity || "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Call Status</p>
|
||||
<Badge className="mt-1" variant={detail.call.callStatus === "failed" ? "destructive" : detail.call.callStatus === "started" ? "secondary" : "default"}>
|
||||
{detail.call.callStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Jessica Answered</p>
|
||||
<p className="font-medium">{detail.call.answered ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Outcome</p>
|
||||
<p className="font-medium">{detail.call.leadOutcome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Summary</p>
|
||||
<p className="font-medium">{detail.call.notificationStatus}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Summary</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{detail.call.summaryText || "No summary available yet."}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Recording Status</p>
|
||||
<p className="font-medium">{detail.call.recordingStatus || "Unavailable"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Transcript Turns</p>
|
||||
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
||||
</div>
|
||||
{detail.call.recordingUrl ? (
|
||||
<div className="md:col-span-2">
|
||||
<Link href={detail.call.recordingUrl} target="_blank" className="inline-flex items-center gap-2 text-sm text-primary hover:underline">
|
||||
Open recording
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.notificationError ? (
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Error</p>
|
||||
<p className="text-sm text-destructive">{detail.call.notificationError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Linked Lead</CardTitle>
|
||||
<CardDescription>
|
||||
{detail.linkedLead ? "Lead created from this phone call." : "No lead was created from this call."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.linkedLead ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Contact</p>
|
||||
<p className="font-medium">
|
||||
{detail.linkedLead.firstName} {detail.linkedLead.lastName}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Type</p>
|
||||
<p className="font-medium">{detail.linkedLead.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email</p>
|
||||
<p className="font-medium break-all">{detail.linkedLead.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Phone</p>
|
||||
<p className="font-medium">{detail.linkedLead.phone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Message</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{detail.linkedLead.message || "—"}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Jessica handled the call, but it did not result in a submitted lead.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transcript</CardTitle>
|
||||
<CardDescription>Complete mirrored transcript for this phone call.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.turns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No transcript turns were captured for this call.</p>
|
||||
) : (
|
||||
detail.turns.map((turn: any) => (
|
||||
<div key={turn.id} className="rounded-lg border p-3">
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-wide">{turn.role}</span>
|
||||
<span>{formatPhoneCallTimestamp(turn.createdAt)}</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm">{turn.text}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Phone Call Detail | Admin",
|
||||
description: "Review a mirrored direct phone call transcript and linked lead details",
|
||||
};
|
||||
187
app/admin/calls/page.tsx
Normal file
187
app/admin/calls/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Calls</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Every direct LiveKit phone call mirrored into RMV admin, including partial and non-lead calls.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin">
|
||||
<Button variant="outline">Back to Admin</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5" />
|
||||
Call Inbox
|
||||
</CardTitle>
|
||||
<CardDescription>Search by caller number, room, summary, or linked lead ID.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_auto]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input name="search" defaultValue={search || ""} placeholder="Search calls" className="pl-9" />
|
||||
</div>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={status || ""}
|
||||
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="started">Started</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<Button type="submit">Filter</Button>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1050px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3 pr-4 font-medium">Caller</th>
|
||||
<th className="py-3 pr-4 font-medium">Started</th>
|
||||
<th className="py-3 pr-4 font-medium">Duration</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 pr-4 font-medium">Answered</th>
|
||||
<th className="py-3 pr-4 font-medium">Transcript</th>
|
||||
<th className="py-3 pr-4 font-medium">Recording</th>
|
||||
<th className="py-3 pr-4 font-medium">Lead</th>
|
||||
<th className="py-3 pr-4 font-medium">Email</th>
|
||||
<th className="py-3 pr-4 font-medium">Summary</th>
|
||||
<th className="py-3 font-medium">Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="py-8 text-center text-muted-foreground">
|
||||
No phone calls matched this filter.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.items.map((call: any) => (
|
||||
<tr key={call.id} className="border-b align-top last:border-b-0">
|
||||
<td className="py-3 pr-4 font-medium">
|
||||
<div>{normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}</div>
|
||||
<div className="text-xs text-muted-foreground">{call.roomName}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{formatPhoneCallTimestamp(call.startedAt)}</td>
|
||||
<td className="py-3 pr-4">{formatPhoneCallDuration(call.durationMs)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant={getStatusVariant(call.callStatus)}>{call.callStatus}</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{call.answered ? "Yes" : "No"}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.transcriptTurnCount > 0 ? `${call.transcriptTurnCount} turns` : "No transcript"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{call.recordingStatus || "Unavailable"}</td>
|
||||
<td className="py-3 pr-4">{call.leadOutcome === "none" ? "—" : call.leadOutcome}</td>
|
||||
<td className="py-3 pr-4">{call.notificationStatus}</td>
|
||||
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
|
||||
<span className="line-clamp-2">{call.summaryText || "No summary yet"}</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Link href={`/admin/calls/${call.id}`}>
|
||||
<Button size="sm" variant="outline">View</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing page {data.pagination.page} of {data.pagination.totalPages} ({data.pagination.total} calls)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{data.pagination.page > 1 ? (
|
||||
<Link
|
||||
href={`/admin/calls?${new URLSearchParams({
|
||||
...(search ? { search } : {}),
|
||||
...(status ? { status } : {}),
|
||||
page: String(data.pagination.page - 1),
|
||||
}).toString()}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">Previous</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{data.pagination.page < data.pagination.totalPages ? (
|
||||
<Link
|
||||
href={`/admin/calls?${new URLSearchParams({
|
||||
...(search ? { search } : {}),
|
||||
...(status ? { status } : {}),
|
||||
page: String(data.pagination.page + 1),
|
||||
}).toString()}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">Next</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Phone Calls | Admin",
|
||||
description: "View direct phone calls, transcript history, and lead outcomes",
|
||||
};
|
||||
|
|
@ -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() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/admin/calls">
|
||||
<Button variant="outline">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
Phone Calls
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/products">
|
||||
<Button variant="outline">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
|
|
|
|||
33
app/api/admin/calls/[id]/route.ts
Normal file
33
app/api/admin/calls/[id]/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
31
app/api/admin/calls/route.ts
Normal file
31
app/api/admin/calls/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
64
app/api/internal/phone-calls/complete/route.ts
Normal file
64
app/api/internal/phone-calls/complete/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
27
app/api/internal/phone-calls/lead-link/route.ts
Normal file
27
app/api/internal/phone-calls/lead-link/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
51
app/api/internal/phone-calls/shared.ts
Normal file
51
app/api/internal/phone-calls/shared.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
37
app/api/internal/phone-calls/start/route.ts
Normal file
37
app/api/internal/phone-calls/start/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
32
app/api/internal/phone-calls/turn/route.ts
Normal file
32
app/api/internal/phone-calls/turn/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
159
convex/leads.ts
159
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<string, unknown>) {
|
||||
if (!webhookUrl) {
|
||||
return { success: false, error: "Webhook URL not configured" };
|
||||
|
|
@ -46,6 +84,7 @@ async function sendWebhook(webhookUrl: string | undefined, payload: Record<strin
|
|||
export const createLead = mutation({
|
||||
args: {
|
||||
type: v.union(v.literal("contact"), v.literal("requestMachine")),
|
||||
idempotencyKey: v.optional(v.string()),
|
||||
firstName: v.string(),
|
||||
lastName: v.string(),
|
||||
email: v.string(),
|
||||
|
|
@ -66,6 +105,8 @@ export const createLead = mutation({
|
|||
consentSourcePage: v.optional(v.string()),
|
||||
marketingConsent: v.optional(v.boolean()),
|
||||
termsAgreement: v.optional(v.boolean()),
|
||||
usesendStatus: v.optional(leadSyncStatus),
|
||||
ghlStatus: v.optional(leadSyncStatus),
|
||||
status: v.union(v.literal("pending"), v.literal("delivered"), v.literal("failed")),
|
||||
error: v.optional(v.string()),
|
||||
},
|
||||
|
|
@ -80,6 +121,120 @@ export const createLead = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
export const ingestLead = mutation({
|
||||
args: {
|
||||
host: v.string(),
|
||||
tenantSlug: v.optional(v.string()),
|
||||
tenantName: v.optional(v.string()),
|
||||
tenantDomains: v.optional(v.array(v.string())),
|
||||
source: v.optional(v.string()),
|
||||
idempotencyKey: v.string(),
|
||||
name: v.string(),
|
||||
firstName: v.optional(v.string()),
|
||||
lastName: v.optional(v.string()),
|
||||
email: v.string(),
|
||||
phone: v.string(),
|
||||
company: v.optional(v.string()),
|
||||
service: v.optional(v.string()),
|
||||
intent: v.optional(v.string()),
|
||||
page: v.optional(v.string()),
|
||||
url: v.optional(v.string()),
|
||||
message: v.string(),
|
||||
employeeCount: v.optional(v.string()),
|
||||
machineType: v.optional(v.string()),
|
||||
machineCount: v.optional(v.string()),
|
||||
serviceTextConsent: v.optional(v.boolean()),
|
||||
marketingTextConsent: v.optional(v.boolean()),
|
||||
consentVersion: v.optional(v.string()),
|
||||
consentCapturedAt: v.optional(v.string()),
|
||||
consentSourcePage: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
215
lib/phone-calls.ts
Normal file
215
lib/phone-calls.ts
Normal file
|
|
@ -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<AdminPhoneCallDetail, "call" | "linkedLead" | "turns">) {
|
||||
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}`;
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue