feat: ship CRM admin and staging sign-in
This commit is contained in:
parent
c0914c92b4
commit
e326cc6bba
26 changed files with 3563 additions and 331 deletions
201
app/admin/contacts/[id]/page.tsx
Normal file
201
app/admin/contacts/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import Link from "next/link"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { fetchQuery } from "convex/nextjs"
|
||||||
|
import { ArrowLeft, ContactRound, MessageSquare } from "lucide-react"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value?: number) {
|
||||||
|
if (!value) {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(value).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminContactDetailPage({ params }: PageProps) {
|
||||||
|
const { id } = await params
|
||||||
|
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
|
||||||
|
contactId: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
href="/admin/contacts"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to contacts
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||||
|
{detail.contact.firstName} {detail.contact.lastName}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Unified CRM record across forms, calls, SMS, and sync imports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ContactRound className="h-5 w-5" />
|
||||||
|
Contact Profile
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Backend-owned identity and sync metadata.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Email
|
||||||
|
</p>
|
||||||
|
<p className="font-medium break-all">
|
||||||
|
{detail.contact.email || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Phone
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{detail.contact.phone || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Company
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{detail.contact.company || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<Badge className="mt-1" variant="secondary">
|
||||||
|
{detail.contact.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
GHL Contact ID
|
||||||
|
</p>
|
||||||
|
<p className="font-medium break-all">
|
||||||
|
{detail.contact.ghlContactId || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Last Activity
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatTimestamp(detail.contact.lastActivityAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Conversations
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Every mirrored conversation associated to this contact.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{detail.conversations.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No conversations are linked to this contact yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
detail.conversations.map((conversation: any) => (
|
||||||
|
<div key={conversation.id} className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{conversation.title || "Untitled conversation"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{conversation.channel} •{" "}
|
||||||
|
{formatTimestamp(conversation.lastMessageAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/admin/conversations/${conversation.id}`}>
|
||||||
|
<Badge variant="outline">{conversation.status}</Badge>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{conversation.lastMessagePreview || "No preview yet"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Timeline</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Calls, messages, recordings, and lead events in one stream.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{detail.timeline.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No timeline activity for this contact yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
detail.timeline.map((item: any) => (
|
||||||
|
<div key={`${item.type}-${item.id}`} className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="uppercase tracking-wide">{item.type}</span>
|
||||||
|
<span>{formatTimestamp(item.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-medium">{item.title || "Untitled"}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{item.body || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Contact Detail | Admin",
|
||||||
|
description: "Review a Rocky CRM contact and full interaction timeline",
|
||||||
|
}
|
||||||
164
app/admin/contacts/page.tsx
Normal file
164
app/admin/contacts/page.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import Link from "next/link"
|
||||||
|
import { fetchQuery } from "convex/nextjs"
|
||||||
|
import { ContactRound, 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"
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{
|
||||||
|
search?: string
|
||||||
|
page?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value?: number) {
|
||||||
|
if (!value) {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(value).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminContactsPage({ searchParams }: PageProps) {
|
||||||
|
const params = await searchParams
|
||||||
|
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
|
||||||
|
const search = params.search?.trim() || undefined
|
||||||
|
|
||||||
|
const data = await fetchQuery(api.crm.listAdminContacts, {
|
||||||
|
search,
|
||||||
|
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">
|
||||||
|
Contacts
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Backend-owned CRM contacts mirrored from forms, phone calls, and
|
||||||
|
GHL sync.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin">
|
||||||
|
<Button variant="outline">Back to Admin</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ContactRound className="h-5 w-5" />
|
||||||
|
Contact Directory
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search by name, email, phone, company, or tag.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_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 contacts"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Filter</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[980px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="py-3 pr-4 font-medium">Contact</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Company</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Status</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Conversations</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Leads</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Last Activity</th>
|
||||||
|
<th className="py-3 font-medium">Open</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No contacts matched this filter.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.items.map((contact: any) => (
|
||||||
|
<tr
|
||||||
|
key={contact.id}
|
||||||
|
className="border-b align-top last:border-b-0"
|
||||||
|
>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="font-medium">
|
||||||
|
{contact.firstName} {contact.lastName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{contact.email || "No email"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{contact.phone || "No phone"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{contact.company || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<Badge variant="secondary">{contact.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">{contact.conversationCount}</td>
|
||||||
|
<td className="py-3 pr-4">{contact.leadCount}</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{formatTimestamp(contact.lastActivityAt)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<Link href={`/admin/contacts/${contact.id}`}>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Contacts | Admin",
|
||||||
|
description: "View backend-owned Rocky contact records",
|
||||||
|
}
|
||||||
241
app/admin/conversations/[id]/page.tsx
Normal file
241
app/admin/conversations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import Link from "next/link"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { fetchQuery } from "convex/nextjs"
|
||||||
|
import { ArrowLeft, ExternalLink, MessageSquare } from "lucide-react"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value?: number) {
|
||||||
|
if (!value) {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(value).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value?: number) {
|
||||||
|
if (!value) {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.round(value / 1000)
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminConversationDetailPage({
|
||||||
|
params,
|
||||||
|
}: PageProps) {
|
||||||
|
const { id } = await params
|
||||||
|
const detail = await fetchQuery(api.crm.getAdminConversationDetail, {
|
||||||
|
conversationId: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
href="/admin/conversations"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to conversations
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||||
|
{detail.conversation.title || "Conversation Detail"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Unified thread for Rocky-owned conversation management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Conversation Status
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Channel, ownership, and sync metadata.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Channel
|
||||||
|
</p>
|
||||||
|
<Badge className="mt-1" variant="outline">
|
||||||
|
{detail.conversation.channel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<Badge className="mt-1" variant="secondary">
|
||||||
|
{detail.conversation.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Contact
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{detail.contact?.name || "Unlinked"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Started
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatTimestamp(detail.conversation.startedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Last Activity
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatTimestamp(detail.conversation.lastMessageAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
GHL Conversation ID
|
||||||
|
</p>
|
||||||
|
<p className="font-medium break-all">
|
||||||
|
{detail.conversation.ghlConversationId || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{detail.conversation.summaryText ? (
|
||||||
|
<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.conversation.summaryText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recordings & Leads</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Call artifacts and related lead outcomes for this thread.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{detail.recordings.map((recording: any) => (
|
||||||
|
<div key={recording.id} className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{recording.recordingStatus || "recording"}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDuration(recording.durationMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{recording.recordingUrl ? (
|
||||||
|
<Link
|
||||||
|
href={recording.recordingUrl}
|
||||||
|
target="_blank"
|
||||||
|
className="mt-2 inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open recording
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{recording.transcriptionText ? (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{recording.transcriptionText}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{detail.leads.map((lead: any) => (
|
||||||
|
<div key={lead.id} className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="font-medium">{lead.type}</p>
|
||||||
|
<Badge variant="secondary">{lead.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{lead.message || lead.intent || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{detail.recordings.length === 0 && detail.leads.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No recordings or linked leads for this conversation yet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Messages</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Full backend-owned thread history for this conversation.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{detail.messages.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No messages have been mirrored into this conversation yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
detail.messages.map((message: any) => (
|
||||||
|
<div key={message.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">
|
||||||
|
{message.channel} • {message.direction}
|
||||||
|
</span>
|
||||||
|
<span>{formatTimestamp(message.sentAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{message.body}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Conversation Detail | Admin",
|
||||||
|
description: "Review a Rocky conversation thread, recordings, and leads",
|
||||||
|
}
|
||||||
208
app/admin/conversations/page.tsx
Normal file
208
app/admin/conversations/page.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import Link from "next/link"
|
||||||
|
import { fetchQuery } from "convex/nextjs"
|
||||||
|
import { MessageSquare, 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"
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{
|
||||||
|
search?: string
|
||||||
|
channel?: "call" | "sms" | "chat" | "unknown"
|
||||||
|
status?: "open" | "closed" | "archived"
|
||||||
|
page?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value?: number) {
|
||||||
|
if (!value) {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(value).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminConversationsPage({
|
||||||
|
searchParams,
|
||||||
|
}: PageProps) {
|
||||||
|
const params = await searchParams
|
||||||
|
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
|
||||||
|
const search = params.search?.trim() || undefined
|
||||||
|
|
||||||
|
const data = await fetchQuery(api.crm.listAdminConversations, {
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
limit: 25,
|
||||||
|
channel: params.channel,
|
||||||
|
status: params.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
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">
|
||||||
|
Conversations
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Unified inbox across backend-owned call and SMS conversation
|
||||||
|
threads.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin">
|
||||||
|
<Button variant="outline">Back to Admin</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Conversation Inbox
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search by contact, conversation preview, phone, email, or external
|
||||||
|
ID.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_170px_170px_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 conversations"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
name="channel"
|
||||||
|
defaultValue={params.channel || ""}
|
||||||
|
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All channels</option>
|
||||||
|
<option value="call">Call</option>
|
||||||
|
<option value="sms">SMS</option>
|
||||||
|
<option value="chat">Chat</option>
|
||||||
|
<option value="unknown">Unknown</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={params.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="open">Open</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
<Button type="submit">Filter</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[1100px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="py-3 pr-4 font-medium">Conversation</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Contact</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Channel</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Status</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Messages</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Recordings</th>
|
||||||
|
<th className="py-3 pr-4 font-medium">Last Activity</th>
|
||||||
|
<th className="py-3 font-medium">Open</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={8}
|
||||||
|
className="py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No conversations matched this filter.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.items.map((conversation: any) => (
|
||||||
|
<tr
|
||||||
|
key={conversation.id}
|
||||||
|
className="border-b align-top last:border-b-0"
|
||||||
|
>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="font-medium">
|
||||||
|
{conversation.title || "Untitled conversation"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{conversation.lastMessagePreview || "No preview yet"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{conversation.contact ? (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{conversation.contact.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{conversation.contact.phone ||
|
||||||
|
conversation.contact.email ||
|
||||||
|
"—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<Badge variant="outline">{conversation.channel}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<Badge variant="secondary">{conversation.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">{conversation.messageCount}</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{conversation.recordingCount}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{formatTimestamp(conversation.lastMessageAt)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<Link href={`/admin/conversations/${conversation.id}`}>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Conversations | Admin",
|
||||||
|
description: "View backend-owned Rocky conversation threads",
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
import Link from "next/link"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
|
import {
|
||||||
|
getAdminUserFromCookies,
|
||||||
|
isAdminUiEnabled,
|
||||||
|
} from "@/lib/server/admin-auth"
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -10,5 +14,29 @@ export default async function AdminLayout({
|
||||||
redirect("/")
|
redirect("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
const adminUser = await getAdminUserFromCookies()
|
||||||
|
if (!adminUser) {
|
||||||
|
redirect("/sign-in")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30">
|
||||||
|
<div className="border-b bg-background">
|
||||||
|
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin" className="font-semibold hover:text-primary">
|
||||||
|
Rocky Admin
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">{adminUser.email}</span>
|
||||||
|
</div>
|
||||||
|
<form action="/api/admin/auth/logout" method="post">
|
||||||
|
<button className="text-muted-foreground hover:text-foreground">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Phone,
|
Phone,
|
||||||
|
MessageSquare,
|
||||||
|
ContactRound,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { fetchAllProducts } from "@/lib/stripe/products"
|
import { fetchAllProducts } from "@/lib/stripe/products"
|
||||||
|
|
||||||
|
|
@ -196,6 +198,18 @@ export default async function AdminDashboard() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Link href="/admin/contacts">
|
||||||
|
<Button variant="outline">
|
||||||
|
<ContactRound className="h-4 w-4 mr-2" />
|
||||||
|
Contacts
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/conversations">
|
||||||
|
<Button variant="outline">
|
||||||
|
<MessageSquare className="h-4 w-4 mr-2" />
|
||||||
|
Conversations
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<Link href="/admin/calls">
|
<Link href="/admin/calls">
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Phone className="h-4 w-4 mr-2" />
|
<Phone className="h-4 w-4 mr-2" />
|
||||||
|
|
|
||||||
37
app/api/admin/auth/login/route.ts
Normal file
37
app/api/admin/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import {
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
createAdminSession,
|
||||||
|
isAdminCredentialLoginConfigured,
|
||||||
|
isAdminCredentialMatch,
|
||||||
|
} from "@/lib/server/admin-auth"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isAdminCredentialLoginConfigured()) {
|
||||||
|
return NextResponse.redirect(new URL("/sign-in?error=config", request.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData()
|
||||||
|
const email = String(formData.get("email") || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
const password = String(formData.get("password") || "")
|
||||||
|
|
||||||
|
if (!isAdminCredentialMatch(email, password)) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/sign-in?error=invalid", request.url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await createAdminSession(email)
|
||||||
|
const response = NextResponse.redirect(new URL("/admin", request.url))
|
||||||
|
response.cookies.set(ADMIN_SESSION_COOKIE, session.token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: true,
|
||||||
|
path: "/",
|
||||||
|
expires: new Date(session.expiresAt),
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
23
app/api/admin/auth/logout/route.ts
Normal file
23
app/api/admin/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import {
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
destroyAdminSession,
|
||||||
|
} from "@/lib/server/admin-auth"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value || null
|
||||||
|
await destroyAdminSession(rawToken)
|
||||||
|
|
||||||
|
const response = NextResponse.redirect(new URL("/sign-in", request.url))
|
||||||
|
response.cookies.set(ADMIN_SESSION_COOKIE, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: true,
|
||||||
|
path: "/",
|
||||||
|
expires: new Date(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
36
app/api/admin/contacts/[id]/route.ts
Normal file
36
app/api/admin/contacts/[id]/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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.crm.getAdminContactDetail, {
|
||||||
|
contactId: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return NextResponse.json({ error: "Contact not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(detail)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load admin contact detail:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to load contact detail" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/admin/contacts/route.ts
Normal file
32
app/api/admin/contacts/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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 page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
|
||||||
|
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
|
||||||
|
|
||||||
|
const data = await fetchQuery(api.crm.listAdminContacts, {
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load admin contacts:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to load contacts" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/api/admin/conversations/[id]/route.ts
Normal file
39
app/api/admin/conversations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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.crm.getAdminConversationDetail, {
|
||||||
|
conversationId: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Conversation not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(detail)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load admin conversation detail:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to load conversation detail" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/api/admin/conversations/route.ts
Normal file
45
app/api/admin/conversations/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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 page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
|
||||||
|
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
|
||||||
|
const channel = searchParams.get("channel")
|
||||||
|
const status = searchParams.get("status")
|
||||||
|
|
||||||
|
const data = await fetchQuery(api.crm.listAdminConversations, {
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
channel:
|
||||||
|
channel === "call" ||
|
||||||
|
channel === "sms" ||
|
||||||
|
channel === "chat" ||
|
||||||
|
channel === "unknown"
|
||||||
|
? channel
|
||||||
|
: undefined,
|
||||||
|
status:
|
||||||
|
status === "open" || status === "closed" || status === "archived"
|
||||||
|
? status
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load admin conversations:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to load conversations" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/internal/ghl/shared.ts
Normal file
51
app/api/internal/ghl/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 getGhlSyncToken() {
|
||||||
|
return String(process.env.GHL_SYNC_CRON_TOKEN || "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireGhlSyncAuth(request: Request) {
|
||||||
|
if (!hasConvexUrl()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Convex is not configured for GHL sync" },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredToken = getGhlSyncToken()
|
||||||
|
if (!configuredToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "GHL 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
|
||||||
|
}
|
||||||
60
app/api/internal/ghl/sync/contacts/route.ts
Normal file
60
app/api/internal/ghl/sync/contacts/route.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { fetchMutation } from "convex/nextjs"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||||
|
import { fetchGhlContacts } from "@/lib/server/ghl-sync"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requireGhlSyncAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||||
|
const fetched = providedItems
|
||||||
|
? {
|
||||||
|
items: providedItems,
|
||||||
|
nextCursor:
|
||||||
|
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||||
|
}
|
||||||
|
: await fetchGhlContacts({
|
||||||
|
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||||
|
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const imported = []
|
||||||
|
for (const item of fetched.items) {
|
||||||
|
const result = await fetchMutation(api.crm.importContact, {
|
||||||
|
provider: "ghl",
|
||||||
|
entityId: String(item.id || ""),
|
||||||
|
payload: item,
|
||||||
|
})
|
||||||
|
imported.push(result?._id || result?.id || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||||
|
provider: "ghl",
|
||||||
|
entityType: "contacts",
|
||||||
|
entityId: "contacts",
|
||||||
|
cursor: fetched.nextCursor,
|
||||||
|
status: "synced",
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
imported: imported.length,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
imported: imported.length,
|
||||||
|
nextCursor: fetched.nextCursor,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync GHL contacts:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to sync GHL contacts" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/api/internal/ghl/sync/conversations/route.ts
Normal file
70
app/api/internal/ghl/sync/conversations/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { fetchMutation } from "convex/nextjs"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||||
|
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requireGhlSyncAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||||
|
const fetched = providedItems
|
||||||
|
? {
|
||||||
|
items: providedItems,
|
||||||
|
nextCursor:
|
||||||
|
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||||
|
}
|
||||||
|
: await fetchGhlMessages({
|
||||||
|
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||||
|
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||||
|
channel: body.channel === "Call" ? "Call" : "SMS",
|
||||||
|
})
|
||||||
|
|
||||||
|
const grouped = new Map<string, any>()
|
||||||
|
for (const item of fetched.items) {
|
||||||
|
const conversationId = String(item.conversationId || item.id || "")
|
||||||
|
if (!conversationId || grouped.has(conversationId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grouped.set(conversationId, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
for (const [entityId, item] of grouped.entries()) {
|
||||||
|
await fetchMutation(api.crm.importConversation, {
|
||||||
|
provider: "ghl",
|
||||||
|
entityId,
|
||||||
|
payload: item,
|
||||||
|
})
|
||||||
|
imported += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||||
|
provider: "ghl",
|
||||||
|
entityType: "conversations",
|
||||||
|
entityId: "conversations",
|
||||||
|
cursor: fetched.nextCursor,
|
||||||
|
status: "synced",
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
imported,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
imported,
|
||||||
|
nextCursor: fetched.nextCursor,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync GHL conversations:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to sync GHL conversations" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/api/internal/ghl/sync/messages/route.ts
Normal file
61
app/api/internal/ghl/sync/messages/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { fetchMutation } from "convex/nextjs"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||||
|
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requireGhlSyncAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||||
|
const fetched = providedItems
|
||||||
|
? {
|
||||||
|
items: providedItems,
|
||||||
|
nextCursor:
|
||||||
|
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||||
|
}
|
||||||
|
: await fetchGhlMessages({
|
||||||
|
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||||
|
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||||
|
channel: body.channel === "Call" ? "Call" : "SMS",
|
||||||
|
})
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
for (const item of fetched.items) {
|
||||||
|
await fetchMutation(api.crm.importMessage, {
|
||||||
|
provider: "ghl",
|
||||||
|
entityId: String(item.id || ""),
|
||||||
|
payload: item,
|
||||||
|
})
|
||||||
|
imported += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||||
|
provider: "ghl",
|
||||||
|
entityType: "messages",
|
||||||
|
entityId: "messages",
|
||||||
|
cursor: fetched.nextCursor,
|
||||||
|
status: "synced",
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
imported,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
imported,
|
||||||
|
nextCursor: fetched.nextCursor,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync GHL messages:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to sync GHL messages" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/internal/ghl/sync/reconcile/route.ts
Normal file
29
app/api/internal/ghl/sync/reconcile/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { fetchMutation } from "convex/nextjs"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requireGhlSyncAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const result = await fetchMutation(api.crm.reconcileExternalState, {
|
||||||
|
provider: body.provider ? String(body.provider) : "ghl",
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reconcile mirrored external state:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to reconcile mirrored external state" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/internal/ghl/sync/recordings/route.ts
Normal file
69
app/api/internal/ghl/sync/recordings/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { fetchMutation } from "convex/nextjs"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||||
|
import { fetchGhlCallLogs } from "@/lib/server/ghl-sync"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authError = await requireGhlSyncAuth(request)
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||||
|
const fetched = providedItems
|
||||||
|
? {
|
||||||
|
items: providedItems,
|
||||||
|
page: typeof body.page === "number" ? body.page : 1,
|
||||||
|
total: providedItems.length,
|
||||||
|
pageSize: providedItems.length,
|
||||||
|
}
|
||||||
|
: await fetchGhlCallLogs({
|
||||||
|
page: typeof body.page === "number" ? body.page : undefined,
|
||||||
|
pageSize: typeof body.pageSize === "number" ? body.pageSize : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
for (const item of fetched.items) {
|
||||||
|
await fetchMutation(api.crm.importRecording, {
|
||||||
|
provider: "ghl",
|
||||||
|
entityId: String(item.id || item.messageId || ""),
|
||||||
|
payload: {
|
||||||
|
...item,
|
||||||
|
recordingId: item.messageId || item.id,
|
||||||
|
transcript: item.transcript,
|
||||||
|
recordingUrl: item.recordingUrl,
|
||||||
|
recordingStatus: item.transcript ? "completed" : "pending",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
imported += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||||
|
provider: "ghl",
|
||||||
|
entityType: "recordings",
|
||||||
|
entityId: "recordings",
|
||||||
|
cursor: `${fetched.page}`,
|
||||||
|
status: "synced",
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
imported,
|
||||||
|
total: fetched.total,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
imported,
|
||||||
|
page: fetched.page,
|
||||||
|
total: fetched.total,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync GHL recordings:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to sync GHL recordings" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,35 @@
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { isAdminUiEnabled } from "@/lib/server/admin-auth"
|
import {
|
||||||
|
getAdminUserFromCookies,
|
||||||
|
isAdminCredentialLoginConfigured,
|
||||||
|
isAdminUiEnabled,
|
||||||
|
} from "@/lib/server/admin-auth"
|
||||||
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||||
|
|
||||||
export default function SignInPage() {
|
type PageProps = {
|
||||||
|
searchParams: Promise<{
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SignInPage({ searchParams }: PageProps) {
|
||||||
if (!isAdminUiEnabled()) {
|
if (!isAdminUiEnabled()) {
|
||||||
redirect("/")
|
redirect("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminUser = await getAdminUserFromCookies()
|
||||||
|
if (adminUser) {
|
||||||
|
redirect("/admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const errorMessage =
|
||||||
|
params.error === "invalid"
|
||||||
|
? "That email or password was not accepted."
|
||||||
|
: params.error === "config"
|
||||||
|
? "Admin sign-in is not fully configured yet."
|
||||||
|
: ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-8 md:py-12">
|
<div className="px-4 py-8 md:py-12">
|
||||||
<div className="mx-auto flex min-h-[calc(100dvh-7rem)] max-w-3xl items-start justify-center md:items-center">
|
<div className="mx-auto flex min-h-[calc(100dvh-7rem)] max-w-3xl items-start justify-center md:items-center">
|
||||||
|
|
@ -19,13 +42,57 @@ export default function SignInPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PublicSurface className="p-6 text-center md:p-8">
|
<PublicSurface className="p-6 text-center md:p-8">
|
||||||
<h2 className="text-2xl font-semibold">
|
{isAdminCredentialLoginConfigured() ? (
|
||||||
Admin sign-in is not configured
|
<form
|
||||||
</h2>
|
action="/api/admin/auth/login"
|
||||||
<p className="mt-3 text-sm text-muted-foreground">
|
method="post"
|
||||||
Enable the admin UI and connect an auth provider before using this
|
className="mx-auto max-w-sm space-y-4 text-left"
|
||||||
area.
|
>
|
||||||
</p>
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="matt@rockymountainvending.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="password">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
<button className="inline-flex h-11 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Admin sign-in is not configured
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
Enable admin UI, Convex, and staging credentials before using
|
||||||
|
this area.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</PublicSurface>
|
</PublicSurface>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
982
convex/crm.ts
Normal file
982
convex/crm.ts
Normal file
|
|
@ -0,0 +1,982 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
import { mutation, query } from "./_generated/server"
|
||||||
|
import { v } from "convex/values"
|
||||||
|
import {
|
||||||
|
ensureConversationParticipant,
|
||||||
|
normalizeEmail,
|
||||||
|
normalizePhone,
|
||||||
|
upsertCallArtifactRecord,
|
||||||
|
upsertContactRecord,
|
||||||
|
upsertConversationRecord,
|
||||||
|
upsertExternalSyncState,
|
||||||
|
upsertMessageRecord,
|
||||||
|
} from "./crmModel"
|
||||||
|
|
||||||
|
function matchesSearch(values: Array<string | undefined>, search: string) {
|
||||||
|
if (!search) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const haystack = values
|
||||||
|
.map((value) => String(value || "").toLowerCase())
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
return haystack.includes(search)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildContactTimeline(ctx, contactId) {
|
||||||
|
const conversations = await ctx.db
|
||||||
|
.query("conversations")
|
||||||
|
.withIndex("by_contactId", (q) => q.eq("contactId", contactId))
|
||||||
|
.collect()
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_contactId", (q) => q.eq("contactId", contactId))
|
||||||
|
.collect()
|
||||||
|
const callArtifacts = await ctx.db
|
||||||
|
.query("callArtifacts")
|
||||||
|
.withIndex("by_contactId", (q) => q.eq("contactId", contactId))
|
||||||
|
.collect()
|
||||||
|
const leads = (await ctx.db.query("leadSubmissions").collect()).filter(
|
||||||
|
(lead) => lead.contactId === contactId
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
...conversations.map((item) => ({
|
||||||
|
id: item._id,
|
||||||
|
type: "conversation",
|
||||||
|
timestamp: item.lastMessageAt || item.startedAt || item.updatedAt,
|
||||||
|
title: item.title || item.channel,
|
||||||
|
body: item.lastMessagePreview || item.summaryText || "",
|
||||||
|
status: item.status,
|
||||||
|
})),
|
||||||
|
...messages.map((item) => ({
|
||||||
|
id: item._id,
|
||||||
|
type: "message",
|
||||||
|
timestamp: item.sentAt,
|
||||||
|
title: `${item.channel.toUpperCase()} ${item.direction || "system"}`,
|
||||||
|
body: item.body,
|
||||||
|
status: item.status,
|
||||||
|
})),
|
||||||
|
...callArtifacts.map((item) => ({
|
||||||
|
id: item._id,
|
||||||
|
type: "recording",
|
||||||
|
timestamp: item.endedAt || item.startedAt || item.updatedAt,
|
||||||
|
title: item.recordingStatus || "recording",
|
||||||
|
body: item.recordingUrl || item.transcriptionText || "",
|
||||||
|
status: item.recordingStatus,
|
||||||
|
})),
|
||||||
|
...leads.map((item) => ({
|
||||||
|
id: item._id,
|
||||||
|
type: "lead",
|
||||||
|
timestamp: item.createdAt,
|
||||||
|
title: item.type,
|
||||||
|
body: item.message || item.intent || "",
|
||||||
|
status: item.status,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
timeline.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
return timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upsertContact = mutation({
|
||||||
|
args: {
|
||||||
|
firstName: v.string(),
|
||||||
|
lastName: v.string(),
|
||||||
|
email: v.optional(v.string()),
|
||||||
|
phone: v.optional(v.string()),
|
||||||
|
company: v.optional(v.string()),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
status: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("active"),
|
||||||
|
v.literal("lead"),
|
||||||
|
v.literal("customer"),
|
||||||
|
v.literal("inactive")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
notes: v.optional(v.string()),
|
||||||
|
ghlContactId: v.optional(v.string()),
|
||||||
|
livekitIdentity: v.optional(v.string()),
|
||||||
|
lastActivityAt: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await upsertContactRecord(ctx, args)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const upsertConversation = mutation({
|
||||||
|
args: {
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
title: v.optional(v.string()),
|
||||||
|
channel: v.union(
|
||||||
|
v.literal("call"),
|
||||||
|
v.literal("sms"),
|
||||||
|
v.literal("chat"),
|
||||||
|
v.literal("unknown")
|
||||||
|
),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
status: v.optional(
|
||||||
|
v.union(v.literal("open"), v.literal("closed"), v.literal("archived"))
|
||||||
|
),
|
||||||
|
direction: v.optional(
|
||||||
|
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("mixed"))
|
||||||
|
),
|
||||||
|
startedAt: v.optional(v.number()),
|
||||||
|
endedAt: v.optional(v.number()),
|
||||||
|
lastMessageAt: v.optional(v.number()),
|
||||||
|
lastMessagePreview: v.optional(v.string()),
|
||||||
|
unreadCount: v.optional(v.number()),
|
||||||
|
summaryText: v.optional(v.string()),
|
||||||
|
ghlConversationId: v.optional(v.string()),
|
||||||
|
livekitRoomName: v.optional(v.string()),
|
||||||
|
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await upsertConversationRecord(ctx, args)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const upsertMessage = mutation({
|
||||||
|
args: {
|
||||||
|
conversationId: v.id("conversations"),
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
direction: v.optional(
|
||||||
|
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("system"))
|
||||||
|
),
|
||||||
|
channel: v.union(
|
||||||
|
v.literal("call"),
|
||||||
|
v.literal("sms"),
|
||||||
|
v.literal("chat"),
|
||||||
|
v.literal("unknown")
|
||||||
|
),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
messageType: v.optional(v.string()),
|
||||||
|
body: v.string(),
|
||||||
|
status: v.optional(v.string()),
|
||||||
|
sentAt: v.optional(v.number()),
|
||||||
|
ghlMessageId: v.optional(v.string()),
|
||||||
|
voiceTranscriptTurnId: v.optional(v.id("voiceTranscriptTurns")),
|
||||||
|
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||||
|
livekitRoomName: v.optional(v.string()),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await upsertMessageRecord(ctx, args)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const upsertCallArtifact = mutation({
|
||||||
|
args: {
|
||||||
|
conversationId: v.id("conversations"),
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
recordingId: v.optional(v.string()),
|
||||||
|
recordingUrl: v.optional(v.string()),
|
||||||
|
recordingStatus: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("pending"),
|
||||||
|
v.literal("starting"),
|
||||||
|
v.literal("recording"),
|
||||||
|
v.literal("completed"),
|
||||||
|
v.literal("failed")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
transcriptionText: v.optional(v.string()),
|
||||||
|
durationMs: v.optional(v.number()),
|
||||||
|
startedAt: v.optional(v.number()),
|
||||||
|
endedAt: v.optional(v.number()),
|
||||||
|
ghlMessageId: v.optional(v.string()),
|
||||||
|
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||||
|
livekitRoomName: v.optional(v.string()),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await upsertCallArtifactRecord(ctx, args)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateContact = mutation({
|
||||||
|
args: {
|
||||||
|
contactId: v.id("contacts"),
|
||||||
|
firstName: v.optional(v.string()),
|
||||||
|
lastName: v.optional(v.string()),
|
||||||
|
email: v.optional(v.string()),
|
||||||
|
phone: v.optional(v.string()),
|
||||||
|
company: v.optional(v.string()),
|
||||||
|
status: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("active"),
|
||||||
|
v.literal("lead"),
|
||||||
|
v.literal("customer"),
|
||||||
|
v.literal("inactive")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
notes: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db.get(args.contactId)
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error("Contact not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.contactId, {
|
||||||
|
firstName: args.firstName ?? existing.firstName,
|
||||||
|
lastName: args.lastName ?? existing.lastName,
|
||||||
|
email: args.email ?? existing.email,
|
||||||
|
normalizedEmail: normalizeEmail(args.email ?? existing.email),
|
||||||
|
phone: args.phone ?? existing.phone,
|
||||||
|
normalizedPhone: normalizePhone(args.phone ?? existing.phone),
|
||||||
|
company: args.company ?? existing.company,
|
||||||
|
status: args.status ?? existing.status,
|
||||||
|
tags: args.tags ?? existing.tags,
|
||||||
|
notes: args.notes ?? existing.notes,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return await ctx.db.get(args.contactId)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const linkLeadSubmission = mutation({
|
||||||
|
args: {
|
||||||
|
leadId: v.id("leadSubmissions"),
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
conversationId: v.optional(v.id("conversations")),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.leadId, {
|
||||||
|
contactId: args.contactId,
|
||||||
|
conversationId: args.conversationId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
return await ctx.db.get(args.leadId)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const importContact = mutation({
|
||||||
|
args: {
|
||||||
|
provider: v.string(),
|
||||||
|
entityId: v.string(),
|
||||||
|
payload: v.any(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const payload = args.payload || {}
|
||||||
|
const contact = await upsertContactRecord(ctx, {
|
||||||
|
firstName:
|
||||||
|
payload.firstName || payload.first_name || payload.name || "Unknown",
|
||||||
|
lastName: payload.lastName || payload.last_name || "Contact",
|
||||||
|
email: payload.email,
|
||||||
|
phone: payload.phone,
|
||||||
|
company: payload.company || payload.companyName,
|
||||||
|
tags: Array.isArray(payload.tags) ? payload.tags : [],
|
||||||
|
status: "lead",
|
||||||
|
source: `${args.provider}:mirror`,
|
||||||
|
ghlContactId: payload.id || args.entityId,
|
||||||
|
lastActivityAt:
|
||||||
|
typeof payload.dateUpdated === "string"
|
||||||
|
? new Date(payload.dateUpdated).getTime()
|
||||||
|
: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await upsertExternalSyncState(ctx, {
|
||||||
|
provider: args.provider,
|
||||||
|
entityType: "contact",
|
||||||
|
entityId: args.entityId,
|
||||||
|
status: "synced",
|
||||||
|
lastAttemptAt: Date.now(),
|
||||||
|
lastSyncedAt: Date.now(),
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
contactId: contact?._id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return contact
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const importConversation = mutation({
|
||||||
|
args: {
|
||||||
|
provider: v.string(),
|
||||||
|
entityId: v.string(),
|
||||||
|
payload: v.any(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const payload = args.payload || {}
|
||||||
|
const contact = await upsertContactRecord(ctx, {
|
||||||
|
firstName: payload.firstName || payload.contactName || "Unknown",
|
||||||
|
lastName: payload.lastName || "Contact",
|
||||||
|
email: payload.email,
|
||||||
|
phone: payload.phone || payload.contactPhone,
|
||||||
|
source: `${args.provider}:mirror`,
|
||||||
|
ghlContactId: payload.contactId,
|
||||||
|
status: "lead",
|
||||||
|
lastActivityAt:
|
||||||
|
typeof payload.dateUpdated === "string"
|
||||||
|
? new Date(payload.dateUpdated).getTime()
|
||||||
|
: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const conversation = await upsertConversationRecord(ctx, {
|
||||||
|
contactId: contact?._id,
|
||||||
|
title: payload.fullName || payload.title || payload.contactName,
|
||||||
|
channel:
|
||||||
|
payload.channel === "SMS" || payload.type === "sms" ? "sms" : "call",
|
||||||
|
source: `${args.provider}:mirror`,
|
||||||
|
status: payload.status || "open",
|
||||||
|
direction: payload.direction || "mixed",
|
||||||
|
startedAt:
|
||||||
|
typeof payload.dateAdded === "string"
|
||||||
|
? new Date(payload.dateAdded).getTime()
|
||||||
|
: Date.now(),
|
||||||
|
endedAt:
|
||||||
|
typeof payload.dateEnded === "string"
|
||||||
|
? new Date(payload.dateEnded).getTime()
|
||||||
|
: undefined,
|
||||||
|
lastMessageAt:
|
||||||
|
typeof payload.lastMessageAt === "string"
|
||||||
|
? new Date(payload.lastMessageAt).getTime()
|
||||||
|
: undefined,
|
||||||
|
lastMessagePreview: payload.lastMessageBody || payload.snippet,
|
||||||
|
summaryText: payload.summary,
|
||||||
|
ghlConversationId: payload.id || args.entityId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ensureConversationParticipant(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
role: "contact",
|
||||||
|
displayName:
|
||||||
|
[contact?.firstName, contact?.lastName].filter(Boolean).join(" ") ||
|
||||||
|
payload.contactName,
|
||||||
|
phone: contact?.phone,
|
||||||
|
email: contact?.email,
|
||||||
|
externalContactId: payload.contactId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await upsertExternalSyncState(ctx, {
|
||||||
|
provider: args.provider,
|
||||||
|
entityType: "conversation",
|
||||||
|
entityId: args.entityId,
|
||||||
|
status: "synced",
|
||||||
|
lastAttemptAt: Date.now(),
|
||||||
|
lastSyncedAt: Date.now(),
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
contactId: contact?._id,
|
||||||
|
conversationId: conversation?._id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return conversation
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const importMessage = mutation({
|
||||||
|
args: {
|
||||||
|
provider: v.string(),
|
||||||
|
entityId: v.string(),
|
||||||
|
payload: v.any(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const payload = args.payload || {}
|
||||||
|
const contact = await upsertContactRecord(ctx, {
|
||||||
|
firstName: payload.firstName || payload.contactName || "Unknown",
|
||||||
|
lastName: payload.lastName || "Contact",
|
||||||
|
email: payload.email,
|
||||||
|
phone: payload.phone,
|
||||||
|
source: `${args.provider}:mirror`,
|
||||||
|
ghlContactId: payload.contactId,
|
||||||
|
status: "lead",
|
||||||
|
})
|
||||||
|
|
||||||
|
const conversation = await upsertConversationRecord(ctx, {
|
||||||
|
contactId: contact?._id,
|
||||||
|
title: payload.contactName,
|
||||||
|
channel:
|
||||||
|
payload.channel === "SMS" || payload.messageType === "SMS"
|
||||||
|
? "sms"
|
||||||
|
: payload.channel === "Call"
|
||||||
|
? "call"
|
||||||
|
: "unknown",
|
||||||
|
source: `${args.provider}:mirror`,
|
||||||
|
status: "open",
|
||||||
|
direction: payload.direction || "mixed",
|
||||||
|
startedAt:
|
||||||
|
typeof payload.dateAdded === "string"
|
||||||
|
? new Date(payload.dateAdded).getTime()
|
||||||
|
: Date.now(),
|
||||||
|
lastMessageAt:
|
||||||
|
typeof payload.dateAdded === "string"
|
||||||
|
? new Date(payload.dateAdded).getTime()
|
||||||
|
: Date.now(),
|
||||||
|
lastMessagePreview: payload.body || payload.message,
|
||||||
|
ghlConversationId: payload.conversationId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ensureConversationParticipant(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
role: "contact",
|
||||||
|
displayName: payload.contactName,
|
||||||
|
phone: contact?.phone,
|
||||||
|
email: contact?.email,
|
||||||
|
externalContactId: payload.contactId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const message = await upsertMessageRecord(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
direction:
|
||||||
|
payload.direction === "inbound"
|
||||||
|
? "inbound"
|
||||||
|
: payload.direction === "outbound"
|
||||||
|
? "outbound"
|
||||||
|
: "system",
|
||||||
|
channel:
|
||||||
|
payload.channel === "SMS" || payload.messageType === "SMS"
|
||||||
|
? "sms"
|
||||||
|
: payload.channel === "Call"
|
||||||
|
? "call"
|
||||||
|
: "unknown",
|
||||||
|
source: `${args.provider}:mirror`,
|
||||||
|
messageType: payload.messageType || payload.type,
|
||||||
|
body: payload.body || payload.message || payload.transcript || "",
|
||||||
|
status: payload.status,
|
||||||
|
sentAt:
|
||||||
|
typeof payload.dateAdded === "string"
|
||||||
|
? new Date(payload.dateAdded).getTime()
|
||||||
|
: Date.now(),
|
||||||
|
ghlMessageId: payload.id || args.entityId,
|
||||||
|
metadata: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
await upsertExternalSyncState(ctx, {
|
||||||
|
provider: args.provider,
|
||||||
|
entityType: "message",
|
||||||
|
entityId: args.entityId,
|
||||||
|
status: "synced",
|
||||||
|
lastAttemptAt: Date.now(),
|
||||||
|
lastSyncedAt: Date.now(),
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
conversationId: conversation?._id,
|
||||||
|
messageId: message?._id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const importRecording = mutation({
|
||||||
|
args: {
|
||||||
|
provider: v.string(),
|
||||||
|
entityId: v.string(),
|
||||||
|
payload: v.any(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const payload = args.payload || {}
|
||||||
|
const conversation = await upsertConversationRecord(ctx, {
|
||||||
|
channel: "call",
|
||||||
|
source: `${args.provider}:mirror`,
|
||||||
|
status: "closed",
|
||||||
|
direction: payload.direction || "mixed",
|
||||||
|
startedAt:
|
||||||
|
typeof payload.createdAt === "string"
|
||||||
|
? new Date(payload.createdAt).getTime()
|
||||||
|
: Date.now(),
|
||||||
|
lastMessageAt:
|
||||||
|
typeof payload.createdAt === "string"
|
||||||
|
? new Date(payload.createdAt).getTime()
|
||||||
|
: Date.now(),
|
||||||
|
lastMessagePreview: payload.summary || payload.transcript,
|
||||||
|
ghlConversationId: payload.conversationId,
|
||||||
|
livekitRoomName: payload.livekitRoomName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const artifact = await upsertCallArtifactRecord(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
source: `${args.provider}:mirror`,
|
||||||
|
recordingId: payload.recordingId || payload.id || args.entityId,
|
||||||
|
recordingUrl: payload.recordingUrl,
|
||||||
|
recordingStatus: payload.recordingStatus || "completed",
|
||||||
|
transcriptionText: payload.transcript,
|
||||||
|
durationMs:
|
||||||
|
typeof payload.durationMs === "number"
|
||||||
|
? payload.durationMs
|
||||||
|
: typeof payload.duration === "number"
|
||||||
|
? payload.duration * 1000
|
||||||
|
: undefined,
|
||||||
|
startedAt:
|
||||||
|
typeof payload.createdAt === "string"
|
||||||
|
? new Date(payload.createdAt).getTime()
|
||||||
|
: undefined,
|
||||||
|
endedAt:
|
||||||
|
typeof payload.endedAt === "string"
|
||||||
|
? new Date(payload.endedAt).getTime()
|
||||||
|
: undefined,
|
||||||
|
ghlMessageId: payload.messageId,
|
||||||
|
livekitRoomName: payload.livekitRoomName,
|
||||||
|
metadata: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
await upsertExternalSyncState(ctx, {
|
||||||
|
provider: args.provider,
|
||||||
|
entityType: "recording",
|
||||||
|
entityId: args.entityId,
|
||||||
|
status: "synced",
|
||||||
|
lastAttemptAt: Date.now(),
|
||||||
|
lastSyncedAt: Date.now(),
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
conversationId: conversation?._id,
|
||||||
|
callArtifactId: artifact?._id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return artifact
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateSyncCheckpoint = mutation({
|
||||||
|
args: {
|
||||||
|
provider: v.string(),
|
||||||
|
entityType: v.string(),
|
||||||
|
entityId: v.string(),
|
||||||
|
cursor: v.optional(v.string()),
|
||||||
|
checksum: v.optional(v.string()),
|
||||||
|
status: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("pending"),
|
||||||
|
v.literal("synced"),
|
||||||
|
v.literal("failed"),
|
||||||
|
v.literal("reconciled"),
|
||||||
|
v.literal("mismatch")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
error: v.optional(v.string()),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await upsertExternalSyncState(ctx, {
|
||||||
|
...args,
|
||||||
|
lastAttemptAt: Date.now(),
|
||||||
|
lastSyncedAt: args.status === "synced" ? Date.now() : undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const reconcileExternalState = mutation({
|
||||||
|
args: {
|
||||||
|
provider: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const states = await ctx.db
|
||||||
|
.query("externalSyncState")
|
||||||
|
.withIndex("by_provider_entityType", (q) => q.eq("provider", args.provider))
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
const mismatches = []
|
||||||
|
for (const state of states) {
|
||||||
|
const missing =
|
||||||
|
state.entityType === "contact"
|
||||||
|
? !(await ctx.db
|
||||||
|
.query("contacts")
|
||||||
|
.withIndex("by_ghlContactId", (q) => q.eq("ghlContactId", state.entityId))
|
||||||
|
.unique())
|
||||||
|
: state.entityType === "conversation"
|
||||||
|
? !(await ctx.db
|
||||||
|
.query("conversations")
|
||||||
|
.withIndex("by_ghlConversationId", (q) =>
|
||||||
|
q.eq("ghlConversationId", state.entityId)
|
||||||
|
)
|
||||||
|
.unique())
|
||||||
|
: state.entityType === "message"
|
||||||
|
? !(await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_ghlMessageId", (q) => q.eq("ghlMessageId", state.entityId))
|
||||||
|
.unique())
|
||||||
|
: state.entityType === "recording"
|
||||||
|
? !(await ctx.db
|
||||||
|
.query("callArtifacts")
|
||||||
|
.withIndex("by_recordingId", (q) => q.eq("recordingId", state.entityId))
|
||||||
|
.unique())
|
||||||
|
: false
|
||||||
|
|
||||||
|
if (missing) {
|
||||||
|
await ctx.db.patch(state._id, {
|
||||||
|
status: "mismatch",
|
||||||
|
error: "Referenced mirrored record is missing locally.",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
mismatches.push(state.entityId)
|
||||||
|
} else {
|
||||||
|
await ctx.db.patch(state._id, {
|
||||||
|
status: "reconciled",
|
||||||
|
error: undefined,
|
||||||
|
lastSyncedAt: state.lastSyncedAt ?? Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: args.provider,
|
||||||
|
checked: states.length,
|
||||||
|
mismatches,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const listAdminContacts = query({
|
||||||
|
args: {
|
||||||
|
search: v.optional(v.string()),
|
||||||
|
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 contacts = await ctx.db.query("contacts").collect()
|
||||||
|
const filtered = contacts.filter((contact) =>
|
||||||
|
matchesSearch(
|
||||||
|
[
|
||||||
|
`${contact.firstName} ${contact.lastName}`,
|
||||||
|
contact.email,
|
||||||
|
contact.phone,
|
||||||
|
contact.company,
|
||||||
|
...(contact.tags || []),
|
||||||
|
],
|
||||||
|
search
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.lastActivityAt || b.updatedAt || 0) - (a.lastActivityAt || a.updatedAt || 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const paged = filtered.slice((page - 1) * limit, page * limit)
|
||||||
|
const items = await Promise.all(
|
||||||
|
paged.map(async (contact) => {
|
||||||
|
const conversations = await ctx.db
|
||||||
|
.query("conversations")
|
||||||
|
.withIndex("by_contactId", (q) => q.eq("contactId", contact._id))
|
||||||
|
.collect()
|
||||||
|
const leads = await ctx.db
|
||||||
|
.query("leadSubmissions")
|
||||||
|
.collect()
|
||||||
|
const contactLeads = leads.filter((lead) => lead.contactId === contact._id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: contact._id,
|
||||||
|
firstName: contact.firstName,
|
||||||
|
lastName: contact.lastName,
|
||||||
|
email: contact.email,
|
||||||
|
phone: contact.phone,
|
||||||
|
company: contact.company,
|
||||||
|
tags: contact.tags || [],
|
||||||
|
status: contact.status || "lead",
|
||||||
|
source: contact.source,
|
||||||
|
ghlContactId: contact.ghlContactId,
|
||||||
|
lastActivityAt: contact.lastActivityAt,
|
||||||
|
conversationCount: conversations.length,
|
||||||
|
leadCount: contactLeads.length,
|
||||||
|
updatedAt: contact.updatedAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: filtered.length,
|
||||||
|
totalPages: Math.max(1, Math.ceil(filtered.length / limit)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getContactTimeline = query({
|
||||||
|
args: {
|
||||||
|
contactId: v.id("contacts"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await buildContactTimeline(ctx, args.contactId)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getAdminContactDetail = query({
|
||||||
|
args: {
|
||||||
|
contactId: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const contact = await ctx.db.get(args.contactId as any)
|
||||||
|
if (!contact) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversations = await ctx.db
|
||||||
|
.query("conversations")
|
||||||
|
.withIndex("by_contactId", (q) => q.eq("contactId", contact._id))
|
||||||
|
.collect()
|
||||||
|
conversations.sort(
|
||||||
|
(a, b) => (b.lastMessageAt || b.updatedAt) - (a.lastMessageAt || a.updatedAt)
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeline = await buildContactTimeline(ctx, contact._id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
contact: {
|
||||||
|
id: contact._id,
|
||||||
|
firstName: contact.firstName,
|
||||||
|
lastName: contact.lastName,
|
||||||
|
email: contact.email,
|
||||||
|
phone: contact.phone,
|
||||||
|
company: contact.company,
|
||||||
|
tags: contact.tags || [],
|
||||||
|
status: contact.status || "lead",
|
||||||
|
source: contact.source,
|
||||||
|
notes: contact.notes,
|
||||||
|
ghlContactId: contact.ghlContactId,
|
||||||
|
lastActivityAt: contact.lastActivityAt,
|
||||||
|
updatedAt: contact.updatedAt,
|
||||||
|
},
|
||||||
|
conversations: conversations.map((conversation) => ({
|
||||||
|
id: conversation._id,
|
||||||
|
channel: conversation.channel,
|
||||||
|
status: conversation.status || "open",
|
||||||
|
title: conversation.title,
|
||||||
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
|
lastMessagePreview: conversation.lastMessagePreview,
|
||||||
|
recordingReady: Boolean(conversation.livekitRoomName || conversation.voiceSessionId),
|
||||||
|
})),
|
||||||
|
timeline,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const listAdminConversations = query({
|
||||||
|
args: {
|
||||||
|
search: v.optional(v.string()),
|
||||||
|
channel: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("call"),
|
||||||
|
v.literal("sms"),
|
||||||
|
v.literal("chat"),
|
||||||
|
v.literal("unknown")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
status: v.optional(
|
||||||
|
v.union(v.literal("open"), v.literal("closed"), v.literal("archived"))
|
||||||
|
),
|
||||||
|
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 conversations = await ctx.db.query("conversations").collect()
|
||||||
|
const filtered = []
|
||||||
|
|
||||||
|
for (const conversation of conversations) {
|
||||||
|
if (args.channel && conversation.channel !== args.channel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (args.status && (conversation.status || "open") !== args.status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = conversation.contactId
|
||||||
|
? await ctx.db.get(conversation.contactId)
|
||||||
|
: null
|
||||||
|
if (
|
||||||
|
!matchesSearch(
|
||||||
|
[
|
||||||
|
conversation.title,
|
||||||
|
conversation.lastMessagePreview,
|
||||||
|
conversation.ghlConversationId,
|
||||||
|
contact
|
||||||
|
? `${contact.firstName} ${contact.lastName}`
|
||||||
|
: undefined,
|
||||||
|
contact?.email,
|
||||||
|
contact?.phone,
|
||||||
|
],
|
||||||
|
search
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.push({ conversation, contact })
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.conversation.lastMessageAt || b.conversation.updatedAt) -
|
||||||
|
(a.conversation.lastMessageAt || a.conversation.updatedAt)
|
||||||
|
)
|
||||||
|
|
||||||
|
const paged = filtered.slice((page - 1) * limit, page * limit)
|
||||||
|
const items = await Promise.all(
|
||||||
|
paged.map(async ({ conversation, contact }) => {
|
||||||
|
const recordings = await ctx.db
|
||||||
|
.query("callArtifacts")
|
||||||
|
.withIndex("by_conversationId", (q) =>
|
||||||
|
q.eq("conversationId", conversation._id)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_conversationId", (q) =>
|
||||||
|
q.eq("conversationId", conversation._id)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: conversation._id,
|
||||||
|
title:
|
||||||
|
conversation.title ||
|
||||||
|
(contact
|
||||||
|
? `${contact.firstName} ${contact.lastName}`.trim()
|
||||||
|
: "Unnamed conversation"),
|
||||||
|
channel: conversation.channel,
|
||||||
|
status: conversation.status || "open",
|
||||||
|
direction: conversation.direction || "mixed",
|
||||||
|
source: conversation.source,
|
||||||
|
startedAt: conversation.startedAt,
|
||||||
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
|
lastMessagePreview: conversation.lastMessagePreview,
|
||||||
|
contact: contact
|
||||||
|
? {
|
||||||
|
id: contact._id,
|
||||||
|
name: `${contact.firstName} ${contact.lastName}`.trim(),
|
||||||
|
email: contact.email,
|
||||||
|
phone: contact.phone,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
messageCount: messages.length,
|
||||||
|
recordingCount: recordings.length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: filtered.length,
|
||||||
|
totalPages: Math.max(1, Math.ceil(filtered.length / limit)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getAdminConversationDetail = query({
|
||||||
|
args: {
|
||||||
|
conversationId: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const conversation = await ctx.db.get(args.conversationId as any)
|
||||||
|
if (!conversation) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = conversation.contactId
|
||||||
|
? await ctx.db.get(conversation.contactId)
|
||||||
|
: null
|
||||||
|
const participants = await ctx.db
|
||||||
|
.query("conversationParticipants")
|
||||||
|
.withIndex("by_conversationId", (q) =>
|
||||||
|
q.eq("conversationId", conversation._id)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_conversationId", (q) =>
|
||||||
|
q.eq("conversationId", conversation._id)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
messages.sort((a, b) => a.sentAt - b.sentAt)
|
||||||
|
const recordings = await ctx.db
|
||||||
|
.query("callArtifacts")
|
||||||
|
.withIndex("by_conversationId", (q) =>
|
||||||
|
q.eq("conversationId", conversation._id)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
const leads = (await ctx.db.query("leadSubmissions").collect()).filter(
|
||||||
|
(lead) =>
|
||||||
|
lead.conversationId === conversation._id ||
|
||||||
|
(contact && lead.contactId === contact._id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversation: {
|
||||||
|
id: conversation._id,
|
||||||
|
title: conversation.title,
|
||||||
|
channel: conversation.channel,
|
||||||
|
status: conversation.status || "open",
|
||||||
|
direction: conversation.direction || "mixed",
|
||||||
|
source: conversation.source,
|
||||||
|
startedAt: conversation.startedAt,
|
||||||
|
endedAt: conversation.endedAt,
|
||||||
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
|
lastMessagePreview: conversation.lastMessagePreview,
|
||||||
|
summaryText: conversation.summaryText,
|
||||||
|
ghlConversationId: conversation.ghlConversationId,
|
||||||
|
livekitRoomName: conversation.livekitRoomName,
|
||||||
|
},
|
||||||
|
contact: contact
|
||||||
|
? {
|
||||||
|
id: contact._id,
|
||||||
|
name: `${contact.firstName} ${contact.lastName}`.trim(),
|
||||||
|
email: contact.email,
|
||||||
|
phone: contact.phone,
|
||||||
|
company: contact.company,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
participants: participants.map((participant) => ({
|
||||||
|
id: participant._id,
|
||||||
|
role: participant.role || "unknown",
|
||||||
|
displayName: participant.displayName,
|
||||||
|
email: participant.email,
|
||||||
|
phone: participant.phone,
|
||||||
|
})),
|
||||||
|
messages: messages.map((message) => ({
|
||||||
|
id: message._id,
|
||||||
|
direction: message.direction || "system",
|
||||||
|
channel: message.channel,
|
||||||
|
source: message.source,
|
||||||
|
body: message.body,
|
||||||
|
status: message.status,
|
||||||
|
sentAt: message.sentAt,
|
||||||
|
})),
|
||||||
|
recordings: recordings.map((recording) => ({
|
||||||
|
id: recording._id,
|
||||||
|
recordingId: recording.recordingId,
|
||||||
|
recordingUrl: recording.recordingUrl,
|
||||||
|
recordingStatus: recording.recordingStatus,
|
||||||
|
transcriptionText: recording.transcriptionText,
|
||||||
|
durationMs: recording.durationMs,
|
||||||
|
startedAt: recording.startedAt,
|
||||||
|
endedAt: recording.endedAt,
|
||||||
|
})),
|
||||||
|
leads: leads.map((lead) => ({
|
||||||
|
id: lead._id,
|
||||||
|
type: lead.type,
|
||||||
|
status: lead.status,
|
||||||
|
message: lead.message,
|
||||||
|
intent: lead.intent,
|
||||||
|
createdAt: lead.createdAt,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
390
convex/crmModel.ts
Normal file
390
convex/crmModel.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
export function normalizeEmail(value?: string) {
|
||||||
|
const normalized = String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
return normalized || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePhone(value?: string) {
|
||||||
|
const digits = String(value || "").replace(/\D/g, "")
|
||||||
|
if (!digits) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return `+1${digits}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) {
|
||||||
|
return `+${digits}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `+${digits}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeStrings(values?: string[]) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
(values || [])
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findContactByIdentity(ctx, args) {
|
||||||
|
if (args.ghlContactId) {
|
||||||
|
const byGhl = await ctx.db
|
||||||
|
.query("contacts")
|
||||||
|
.withIndex("by_ghlContactId", (q) => q.eq("ghlContactId", args.ghlContactId))
|
||||||
|
.unique()
|
||||||
|
if (byGhl) {
|
||||||
|
return byGhl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = normalizeEmail(args.email)
|
||||||
|
if (normalizedEmail) {
|
||||||
|
const byEmail = await ctx.db
|
||||||
|
.query("contacts")
|
||||||
|
.withIndex("by_normalizedEmail", (q) =>
|
||||||
|
q.eq("normalizedEmail", normalizedEmail)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
if (byEmail) {
|
||||||
|
return byEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPhone = normalizePhone(args.phone)
|
||||||
|
if (normalizedPhone) {
|
||||||
|
const byPhone = await ctx.db
|
||||||
|
.query("contacts")
|
||||||
|
.withIndex("by_normalizedPhone", (q) =>
|
||||||
|
q.eq("normalizedPhone", normalizedPhone)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
if (byPhone) {
|
||||||
|
return byPhone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertContactRecord(ctx, input) {
|
||||||
|
const now = input.updatedAt ?? Date.now()
|
||||||
|
const normalizedEmail = normalizeEmail(input.email)
|
||||||
|
const normalizedPhone = normalizePhone(input.phone)
|
||||||
|
const existing = await findContactByIdentity(ctx, {
|
||||||
|
ghlContactId: input.ghlContactId,
|
||||||
|
email: normalizedEmail,
|
||||||
|
phone: normalizedPhone,
|
||||||
|
})
|
||||||
|
|
||||||
|
const patch = {
|
||||||
|
firstName: String(input.firstName || existing?.firstName || "Unknown"),
|
||||||
|
lastName: String(input.lastName || existing?.lastName || "Contact"),
|
||||||
|
email: input.email || existing?.email,
|
||||||
|
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
|
||||||
|
phone: input.phone || existing?.phone,
|
||||||
|
normalizedPhone: normalizedPhone || existing?.normalizedPhone,
|
||||||
|
company: input.company ?? existing?.company,
|
||||||
|
tags: dedupeStrings([...(existing?.tags || []), ...(input.tags || [])]),
|
||||||
|
status: input.status || existing?.status || "lead",
|
||||||
|
source: input.source || existing?.source,
|
||||||
|
notes: input.notes ?? existing?.notes,
|
||||||
|
ghlContactId: input.ghlContactId || existing?.ghlContactId,
|
||||||
|
livekitIdentity: input.livekitIdentity || existing?.livekitIdentity,
|
||||||
|
lastActivityAt:
|
||||||
|
input.lastActivityAt ?? existing?.lastActivityAt ?? input.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, patch)
|
||||||
|
return await ctx.db.get(existing._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await ctx.db.insert("contacts", {
|
||||||
|
...patch,
|
||||||
|
createdAt: input.createdAt ?? now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return await ctx.db.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertConversationRecord(ctx, input) {
|
||||||
|
const now = input.updatedAt ?? Date.now()
|
||||||
|
let existing = null
|
||||||
|
|
||||||
|
if (input.ghlConversationId) {
|
||||||
|
existing = await ctx.db
|
||||||
|
.query("conversations")
|
||||||
|
.withIndex("by_ghlConversationId", (q) =>
|
||||||
|
q.eq("ghlConversationId", input.ghlConversationId)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing && input.livekitRoomName) {
|
||||||
|
existing = await ctx.db
|
||||||
|
.query("conversations")
|
||||||
|
.withIndex("by_livekitRoomName", (q) =>
|
||||||
|
q.eq("livekitRoomName", input.livekitRoomName)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing && input.voiceSessionId) {
|
||||||
|
existing = await ctx.db
|
||||||
|
.query("conversations")
|
||||||
|
.withIndex("by_voiceSessionId", (q) =>
|
||||||
|
q.eq("voiceSessionId", input.voiceSessionId)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = {
|
||||||
|
contactId: input.contactId ?? existing?.contactId,
|
||||||
|
title: input.title || existing?.title,
|
||||||
|
channel: input.channel || existing?.channel || "unknown",
|
||||||
|
source: input.source || existing?.source,
|
||||||
|
status: input.status || existing?.status || "open",
|
||||||
|
direction: input.direction || existing?.direction || "mixed",
|
||||||
|
startedAt: input.startedAt ?? existing?.startedAt ?? now,
|
||||||
|
endedAt: input.endedAt ?? existing?.endedAt,
|
||||||
|
lastMessageAt: input.lastMessageAt ?? existing?.lastMessageAt,
|
||||||
|
lastMessagePreview: input.lastMessagePreview ?? existing?.lastMessagePreview,
|
||||||
|
unreadCount: input.unreadCount ?? existing?.unreadCount ?? 0,
|
||||||
|
summaryText: input.summaryText ?? existing?.summaryText,
|
||||||
|
ghlConversationId: input.ghlConversationId || existing?.ghlConversationId,
|
||||||
|
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
|
||||||
|
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, patch)
|
||||||
|
return await ctx.db.get(existing._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await ctx.db.insert("conversations", {
|
||||||
|
...patch,
|
||||||
|
createdAt: input.createdAt ?? now,
|
||||||
|
})
|
||||||
|
return await ctx.db.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureConversationParticipant(ctx, input) {
|
||||||
|
const participants = await ctx.db
|
||||||
|
.query("conversationParticipants")
|
||||||
|
.withIndex("by_conversationId", (q) =>
|
||||||
|
q.eq("conversationId", input.conversationId)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
const normalizedEmail = normalizeEmail(input.email)
|
||||||
|
const normalizedPhone = normalizePhone(input.phone)
|
||||||
|
const existing = participants.find((participant) => {
|
||||||
|
if (input.contactId && participant.contactId === input.contactId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.externalContactId &&
|
||||||
|
participant.externalContactId === input.externalContactId
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalizedEmail && participant.normalizedEmail === normalizedEmail) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalizedPhone && participant.normalizedPhone === normalizedPhone) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const patch = {
|
||||||
|
contactId: input.contactId ?? existing?.contactId,
|
||||||
|
role: input.role || existing?.role || "unknown",
|
||||||
|
displayName: input.displayName || existing?.displayName,
|
||||||
|
phone: input.phone || existing?.phone,
|
||||||
|
normalizedPhone: normalizedPhone || existing?.normalizedPhone,
|
||||||
|
email: input.email || existing?.email,
|
||||||
|
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
|
||||||
|
externalContactId: input.externalContactId || existing?.externalContactId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, patch)
|
||||||
|
return await ctx.db.get(existing._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await ctx.db.insert("conversationParticipants", {
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
...patch,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
return await ctx.db.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertMessageRecord(ctx, input) {
|
||||||
|
let existing = null
|
||||||
|
|
||||||
|
if (input.ghlMessageId) {
|
||||||
|
existing = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_ghlMessageId", (q) =>
|
||||||
|
q.eq("ghlMessageId", input.ghlMessageId)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing && input.voiceTranscriptTurnId) {
|
||||||
|
existing = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_voiceTranscriptTurnId", (q) =>
|
||||||
|
q.eq("voiceTranscriptTurnId", input.voiceTranscriptTurnId)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = input.updatedAt ?? Date.now()
|
||||||
|
const patch = {
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
contactId: input.contactId,
|
||||||
|
direction: input.direction || existing?.direction || "system",
|
||||||
|
channel: input.channel || existing?.channel || "unknown",
|
||||||
|
source: input.source || existing?.source,
|
||||||
|
messageType: input.messageType || existing?.messageType,
|
||||||
|
body: String(input.body || existing?.body || "").trim(),
|
||||||
|
status: input.status || existing?.status,
|
||||||
|
sentAt: input.sentAt ?? existing?.sentAt ?? now,
|
||||||
|
ghlMessageId: input.ghlMessageId || existing?.ghlMessageId,
|
||||||
|
voiceTranscriptTurnId:
|
||||||
|
input.voiceTranscriptTurnId ?? existing?.voiceTranscriptTurnId,
|
||||||
|
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
|
||||||
|
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
|
||||||
|
metadata: input.metadata || existing?.metadata,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
let message
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, patch)
|
||||||
|
message = await ctx.db.get(existing._id)
|
||||||
|
} else {
|
||||||
|
const id = await ctx.db.insert("messages", {
|
||||||
|
...patch,
|
||||||
|
createdAt: input.createdAt ?? now,
|
||||||
|
})
|
||||||
|
message = await ctx.db.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(input.conversationId, {
|
||||||
|
lastMessageAt: patch.sentAt,
|
||||||
|
lastMessagePreview: patch.body.slice(0, 240),
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertCallArtifactRecord(ctx, input) {
|
||||||
|
let existing = null
|
||||||
|
|
||||||
|
if (input.recordingId) {
|
||||||
|
existing = await ctx.db
|
||||||
|
.query("callArtifacts")
|
||||||
|
.withIndex("by_recordingId", (q) => q.eq("recordingId", input.recordingId))
|
||||||
|
.unique()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing && input.voiceSessionId) {
|
||||||
|
existing = await ctx.db
|
||||||
|
.query("callArtifacts")
|
||||||
|
.withIndex("by_voiceSessionId", (q) =>
|
||||||
|
q.eq("voiceSessionId", input.voiceSessionId)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing && input.ghlMessageId) {
|
||||||
|
existing = await ctx.db
|
||||||
|
.query("callArtifacts")
|
||||||
|
.withIndex("by_ghlMessageId", (q) =>
|
||||||
|
q.eq("ghlMessageId", input.ghlMessageId)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = input.updatedAt ?? Date.now()
|
||||||
|
const patch = {
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
contactId: input.contactId ?? existing?.contactId,
|
||||||
|
source: input.source || existing?.source,
|
||||||
|
recordingId: input.recordingId || existing?.recordingId,
|
||||||
|
recordingUrl: input.recordingUrl || existing?.recordingUrl,
|
||||||
|
recordingStatus: input.recordingStatus || existing?.recordingStatus,
|
||||||
|
transcriptionText: input.transcriptionText ?? existing?.transcriptionText,
|
||||||
|
durationMs: input.durationMs ?? existing?.durationMs,
|
||||||
|
startedAt: input.startedAt ?? existing?.startedAt,
|
||||||
|
endedAt: input.endedAt ?? existing?.endedAt,
|
||||||
|
ghlMessageId: input.ghlMessageId || existing?.ghlMessageId,
|
||||||
|
voiceSessionId: input.voiceSessionId ?? existing?.voiceSessionId,
|
||||||
|
livekitRoomName: input.livekitRoomName || existing?.livekitRoomName,
|
||||||
|
metadata: input.metadata || existing?.metadata,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, patch)
|
||||||
|
return await ctx.db.get(existing._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await ctx.db.insert("callArtifacts", {
|
||||||
|
...patch,
|
||||||
|
createdAt: input.createdAt ?? now,
|
||||||
|
})
|
||||||
|
return await ctx.db.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertExternalSyncState(ctx, input) {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("externalSyncState")
|
||||||
|
.withIndex("by_provider_entityType_entityId", (q) =>
|
||||||
|
q
|
||||||
|
.eq("provider", input.provider)
|
||||||
|
.eq("entityType", input.entityType)
|
||||||
|
.eq("entityId", input.entityId)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
|
||||||
|
const patch = {
|
||||||
|
cursor: input.cursor ?? existing?.cursor,
|
||||||
|
checksum: input.checksum ?? existing?.checksum,
|
||||||
|
status: input.status || existing?.status || "pending",
|
||||||
|
lastAttemptAt: input.lastAttemptAt ?? existing?.lastAttemptAt ?? Date.now(),
|
||||||
|
lastSyncedAt: input.lastSyncedAt ?? existing?.lastSyncedAt,
|
||||||
|
error: input.error ?? existing?.error,
|
||||||
|
metadata: input.metadata ?? existing?.metadata,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, patch)
|
||||||
|
return await ctx.db.get(existing._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await ctx.db.insert("externalSyncState", {
|
||||||
|
provider: input.provider,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
...patch,
|
||||||
|
})
|
||||||
|
return await ctx.db.get(id)
|
||||||
|
}
|
||||||
217
convex/leads.ts
217
convex/leads.ts
|
|
@ -1,23 +1,12 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { action, mutation } from "./_generated/server"
|
import { action, mutation } from "./_generated/server"
|
||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
|
import {
|
||||||
function normalizePhone(value?: string | null) {
|
ensureConversationParticipant,
|
||||||
const digits = String(value || "").replace(/\D/g, "")
|
upsertContactRecord,
|
||||||
if (!digits) {
|
upsertConversationRecord,
|
||||||
return undefined
|
upsertMessageRecord,
|
||||||
}
|
} from "./crmModel"
|
||||||
if (digits.length === 10) {
|
|
||||||
return `+1${digits}`
|
|
||||||
}
|
|
||||||
if (digits.length === 11 && digits.startsWith("1")) {
|
|
||||||
return `+${digits}`
|
|
||||||
}
|
|
||||||
if (digits.length >= 11) {
|
|
||||||
return `+${digits}`
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const leadSyncStatus = v.union(
|
const leadSyncStatus = v.union(
|
||||||
v.literal("pending"),
|
v.literal("pending"),
|
||||||
|
|
@ -136,49 +125,64 @@ export const createLead = mutation({
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const normalizedPhone = normalizePhone(args.phone)
|
const contact = await upsertContactRecord(ctx, {
|
||||||
const leadId = await ctx.db.insert("leadSubmissions", {
|
firstName: args.firstName,
|
||||||
|
lastName: args.lastName,
|
||||||
|
email: args.email,
|
||||||
|
phone: args.phone,
|
||||||
|
company: args.company,
|
||||||
|
source: args.source,
|
||||||
|
status: args.status === "delivered" ? "active" : "lead",
|
||||||
|
lastActivityAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
const conversation = await upsertConversationRecord(ctx, {
|
||||||
|
contactId: contact?._id,
|
||||||
|
title:
|
||||||
|
args.type === "requestMachine"
|
||||||
|
? "Machine request"
|
||||||
|
: "Website contact",
|
||||||
|
channel: "chat",
|
||||||
|
source: args.source || "website",
|
||||||
|
status: args.status === "failed" ? "archived" : "open",
|
||||||
|
direction: "inbound",
|
||||||
|
startedAt: now,
|
||||||
|
lastMessageAt: now,
|
||||||
|
lastMessagePreview: args.message || args.intent,
|
||||||
|
summaryText: args.intent,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ensureConversationParticipant(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
role: "contact",
|
||||||
|
displayName: `${args.firstName} ${args.lastName}`.trim(),
|
||||||
|
phone: args.phone,
|
||||||
|
email: args.email,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (args.message || args.intent) {
|
||||||
|
await upsertMessageRecord(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
direction: "inbound",
|
||||||
|
channel: "chat",
|
||||||
|
source: args.source || "website",
|
||||||
|
messageType: args.type,
|
||||||
|
body: args.message || args.intent || "",
|
||||||
|
status: args.status,
|
||||||
|
sentAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert("leadSubmissions", {
|
||||||
...args,
|
...args,
|
||||||
normalizedPhone,
|
contactId: contact?._id,
|
||||||
|
conversationId: conversation?._id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
deliveredAt: args.status === "delivered" ? now : undefined,
|
deliveredAt: args.status === "delivered" ? now : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (normalizedPhone) {
|
|
||||||
const displayName = `${args.firstName} ${args.lastName}`.trim()
|
|
||||||
const existingProfile = await ctx.db
|
|
||||||
.query("contactProfiles")
|
|
||||||
.withIndex("by_normalizedPhone", (q) =>
|
|
||||||
q.eq("normalizedPhone", normalizedPhone)
|
|
||||||
)
|
|
||||||
.unique()
|
|
||||||
|
|
||||||
const patch = {
|
|
||||||
displayName: displayName || existingProfile?.displayName,
|
|
||||||
firstName: args.firstName || existingProfile?.firstName,
|
|
||||||
lastName: args.lastName || existingProfile?.lastName,
|
|
||||||
email: args.email || existingProfile?.email,
|
|
||||||
company: args.company || existingProfile?.company,
|
|
||||||
lastIntent: args.intent || existingProfile?.lastIntent,
|
|
||||||
lastLeadOutcome: args.type,
|
|
||||||
lastSummaryText: args.message || existingProfile?.lastSummaryText,
|
|
||||||
source: args.source || existingProfile?.source,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingProfile) {
|
|
||||||
await ctx.db.patch(existingProfile._id, patch)
|
|
||||||
} else {
|
|
||||||
await ctx.db.insert("contactProfiles", {
|
|
||||||
normalizedPhone,
|
|
||||||
...patch,
|
|
||||||
createdAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return leadId
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -230,7 +234,54 @@ export const ingestLead = mutation({
|
||||||
const fallbackName = splitName(args.name)
|
const fallbackName = splitName(args.name)
|
||||||
const type = mapServiceToType(args.service)
|
const type = mapServiceToType(args.service)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const normalizedPhone = normalizePhone(args.phone)
|
const contact = await upsertContactRecord(ctx, {
|
||||||
|
firstName: args.firstName || fallbackName.firstName,
|
||||||
|
lastName: args.lastName || fallbackName.lastName,
|
||||||
|
email: args.email,
|
||||||
|
phone: args.phone,
|
||||||
|
company: args.company,
|
||||||
|
source: args.source,
|
||||||
|
status: "lead",
|
||||||
|
lastActivityAt: now,
|
||||||
|
})
|
||||||
|
const conversation = await upsertConversationRecord(ctx, {
|
||||||
|
contactId: contact?._id,
|
||||||
|
title: type === "requestMachine" ? "Machine request" : "Website contact",
|
||||||
|
channel: "chat",
|
||||||
|
source: args.source || "website",
|
||||||
|
status: "open",
|
||||||
|
direction: "inbound",
|
||||||
|
startedAt: now,
|
||||||
|
lastMessageAt: now,
|
||||||
|
lastMessagePreview: args.message || args.intent,
|
||||||
|
summaryText: args.intent || args.service,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ensureConversationParticipant(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
role: "contact",
|
||||||
|
displayName: `${args.firstName || fallbackName.firstName} ${args.lastName || fallbackName.lastName}`.trim(),
|
||||||
|
phone: args.phone,
|
||||||
|
email: args.email,
|
||||||
|
})
|
||||||
|
|
||||||
|
await upsertMessageRecord(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
direction: "inbound",
|
||||||
|
channel: "chat",
|
||||||
|
source: args.source || "website",
|
||||||
|
messageType: type,
|
||||||
|
body: args.message,
|
||||||
|
status: "pending",
|
||||||
|
sentAt: now,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
intent: args.intent,
|
||||||
|
service: args.service,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
const leadId = await ctx.db.insert("leadSubmissions", {
|
const leadId = await ctx.db.insert("leadSubmissions", {
|
||||||
type,
|
type,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
|
@ -239,7 +290,6 @@ export const ingestLead = mutation({
|
||||||
lastName: args.lastName || fallbackName.lastName,
|
lastName: args.lastName || fallbackName.lastName,
|
||||||
email: args.email,
|
email: args.email,
|
||||||
phone: args.phone,
|
phone: args.phone,
|
||||||
normalizedPhone,
|
|
||||||
company: args.company,
|
company: args.company,
|
||||||
intent: args.intent || args.service,
|
intent: args.intent || args.service,
|
||||||
message: args.message,
|
message: args.message,
|
||||||
|
|
@ -254,47 +304,14 @@ export const ingestLead = mutation({
|
||||||
consentVersion: args.consentVersion,
|
consentVersion: args.consentVersion,
|
||||||
consentCapturedAt: args.consentCapturedAt,
|
consentCapturedAt: args.consentCapturedAt,
|
||||||
consentSourcePage: args.consentSourcePage,
|
consentSourcePage: args.consentSourcePage,
|
||||||
|
contactId: contact?._id,
|
||||||
|
conversationId: conversation?._id,
|
||||||
usesendStatus: "pending",
|
usesendStatus: "pending",
|
||||||
ghlStatus: "pending",
|
ghlStatus: "pending",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (normalizedPhone) {
|
|
||||||
const displayName = `${args.firstName || fallbackName.firstName} ${args.lastName || fallbackName.lastName}`.trim()
|
|
||||||
const existingProfile = await ctx.db
|
|
||||||
.query("contactProfiles")
|
|
||||||
.withIndex("by_normalizedPhone", (q) =>
|
|
||||||
q.eq("normalizedPhone", normalizedPhone)
|
|
||||||
)
|
|
||||||
.unique()
|
|
||||||
|
|
||||||
const patch = {
|
|
||||||
displayName: displayName || existingProfile?.displayName,
|
|
||||||
firstName:
|
|
||||||
args.firstName || fallbackName.firstName || existingProfile?.firstName,
|
|
||||||
lastName:
|
|
||||||
args.lastName || fallbackName.lastName || existingProfile?.lastName,
|
|
||||||
email: args.email || existingProfile?.email,
|
|
||||||
company: args.company || existingProfile?.company,
|
|
||||||
lastIntent: args.intent || args.service || existingProfile?.lastIntent,
|
|
||||||
lastLeadOutcome: type,
|
|
||||||
lastSummaryText: args.message || existingProfile?.lastSummaryText,
|
|
||||||
source: args.source || existingProfile?.source,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingProfile) {
|
|
||||||
await ctx.db.patch(existingProfile._id, patch)
|
|
||||||
} else {
|
|
||||||
await ctx.db.insert("contactProfiles", {
|
|
||||||
normalizedPhone,
|
|
||||||
...patch,
|
|
||||||
createdAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inserted: true,
|
inserted: true,
|
||||||
leadId,
|
leadId,
|
||||||
|
|
@ -332,6 +349,22 @@ export const updateLeadSyncStatus = mutation({
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (lead.contactId) {
|
||||||
|
await ctx.db.patch(lead.contactId, {
|
||||||
|
status: status === "delivered" ? "active" : "lead",
|
||||||
|
lastActivityAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lead.conversationId) {
|
||||||
|
await ctx.db.patch(lead.conversationId, {
|
||||||
|
status: status === "failed" ? "archived" : "open",
|
||||||
|
lastMessageAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return await ctx.db.get(args.leadId)
|
return await ctx.db.get(args.leadId)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
239
convex/schema.ts
239
convex/schema.ts
|
|
@ -155,7 +155,6 @@ export default defineSchema({
|
||||||
lastName: v.string(),
|
lastName: v.string(),
|
||||||
email: v.string(),
|
email: v.string(),
|
||||||
phone: v.string(),
|
phone: v.string(),
|
||||||
normalizedPhone: v.optional(v.string()),
|
|
||||||
company: v.optional(v.string()),
|
company: v.optional(v.string()),
|
||||||
intent: v.optional(v.string()),
|
intent: v.optional(v.string()),
|
||||||
message: v.optional(v.string()),
|
message: v.optional(v.string()),
|
||||||
|
|
@ -190,6 +189,8 @@ export default defineSchema({
|
||||||
v.literal("skipped")
|
v.literal("skipped")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
conversationId: v.optional(v.id("conversations")),
|
||||||
error: v.optional(v.string()),
|
error: v.optional(v.string()),
|
||||||
deliveredAt: v.optional(v.number()),
|
deliveredAt: v.optional(v.number()),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
|
|
@ -198,34 +199,7 @@ export default defineSchema({
|
||||||
.index("by_type", ["type"])
|
.index("by_type", ["type"])
|
||||||
.index("by_status", ["status"])
|
.index("by_status", ["status"])
|
||||||
.index("by_createdAt", ["createdAt"])
|
.index("by_createdAt", ["createdAt"])
|
||||||
.index("by_idempotencyKey", ["idempotencyKey"])
|
.index("by_idempotencyKey", ["idempotencyKey"]),
|
||||||
.index("by_normalizedPhone", ["normalizedPhone"]),
|
|
||||||
|
|
||||||
contactProfiles: defineTable({
|
|
||||||
normalizedPhone: v.string(),
|
|
||||||
displayName: v.optional(v.string()),
|
|
||||||
firstName: v.optional(v.string()),
|
|
||||||
lastName: v.optional(v.string()),
|
|
||||||
email: v.optional(v.string()),
|
|
||||||
company: v.optional(v.string()),
|
|
||||||
lastIntent: v.optional(v.string()),
|
|
||||||
lastLeadOutcome: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal("none"),
|
|
||||||
v.literal("contact"),
|
|
||||||
v.literal("requestMachine")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
lastSummaryText: v.optional(v.string()),
|
|
||||||
lastCallAt: v.optional(v.number()),
|
|
||||||
lastReminderAt: v.optional(v.number()),
|
|
||||||
reminderNotes: v.optional(v.string()),
|
|
||||||
source: v.optional(v.string()),
|
|
||||||
createdAt: v.number(),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
})
|
|
||||||
.index("by_normalizedPhone", ["normalizedPhone"])
|
|
||||||
.index("by_updatedAt", ["updatedAt"]),
|
|
||||||
|
|
||||||
adminUsers: defineTable({
|
adminUsers: defineTable({
|
||||||
email: v.string(),
|
email: v.string(),
|
||||||
|
|
@ -271,17 +245,191 @@ export default defineSchema({
|
||||||
.index("by_kind", ["kind"])
|
.index("by_kind", ["kind"])
|
||||||
.index("by_status", ["status"]),
|
.index("by_status", ["status"]),
|
||||||
|
|
||||||
|
contacts: defineTable({
|
||||||
|
firstName: v.string(),
|
||||||
|
lastName: v.string(),
|
||||||
|
email: v.optional(v.string()),
|
||||||
|
normalizedEmail: v.optional(v.string()),
|
||||||
|
phone: v.optional(v.string()),
|
||||||
|
normalizedPhone: v.optional(v.string()),
|
||||||
|
company: v.optional(v.string()),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
status: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("active"),
|
||||||
|
v.literal("lead"),
|
||||||
|
v.literal("customer"),
|
||||||
|
v.literal("inactive")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
notes: v.optional(v.string()),
|
||||||
|
ghlContactId: v.optional(v.string()),
|
||||||
|
livekitIdentity: v.optional(v.string()),
|
||||||
|
lastActivityAt: v.optional(v.number()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_normalizedEmail", ["normalizedEmail"])
|
||||||
|
.index("by_normalizedPhone", ["normalizedPhone"])
|
||||||
|
.index("by_ghlContactId", ["ghlContactId"])
|
||||||
|
.index("by_lastActivityAt", ["lastActivityAt"])
|
||||||
|
.index("by_updatedAt", ["updatedAt"]),
|
||||||
|
|
||||||
|
conversations: defineTable({
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
title: v.optional(v.string()),
|
||||||
|
channel: v.union(
|
||||||
|
v.literal("call"),
|
||||||
|
v.literal("sms"),
|
||||||
|
v.literal("chat"),
|
||||||
|
v.literal("unknown")
|
||||||
|
),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
status: v.optional(
|
||||||
|
v.union(v.literal("open"), v.literal("closed"), v.literal("archived"))
|
||||||
|
),
|
||||||
|
direction: v.optional(
|
||||||
|
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("mixed"))
|
||||||
|
),
|
||||||
|
startedAt: v.number(),
|
||||||
|
endedAt: v.optional(v.number()),
|
||||||
|
lastMessageAt: v.optional(v.number()),
|
||||||
|
lastMessagePreview: v.optional(v.string()),
|
||||||
|
unreadCount: v.optional(v.number()),
|
||||||
|
summaryText: v.optional(v.string()),
|
||||||
|
ghlConversationId: v.optional(v.string()),
|
||||||
|
livekitRoomName: v.optional(v.string()),
|
||||||
|
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_contactId", ["contactId"])
|
||||||
|
.index("by_channel", ["channel"])
|
||||||
|
.index("by_status", ["status"])
|
||||||
|
.index("by_ghlConversationId", ["ghlConversationId"])
|
||||||
|
.index("by_livekitRoomName", ["livekitRoomName"])
|
||||||
|
.index("by_voiceSessionId", ["voiceSessionId"])
|
||||||
|
.index("by_lastMessageAt", ["lastMessageAt"]),
|
||||||
|
|
||||||
|
conversationParticipants: defineTable({
|
||||||
|
conversationId: v.id("conversations"),
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
role: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("contact"),
|
||||||
|
v.literal("agent"),
|
||||||
|
v.literal("system"),
|
||||||
|
v.literal("unknown")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
displayName: v.optional(v.string()),
|
||||||
|
phone: v.optional(v.string()),
|
||||||
|
normalizedPhone: v.optional(v.string()),
|
||||||
|
email: v.optional(v.string()),
|
||||||
|
normalizedEmail: v.optional(v.string()),
|
||||||
|
externalContactId: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_conversationId", ["conversationId"])
|
||||||
|
.index("by_contactId", ["contactId"])
|
||||||
|
.index("by_externalContactId", ["externalContactId"]),
|
||||||
|
|
||||||
|
messages: defineTable({
|
||||||
|
conversationId: v.id("conversations"),
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
direction: v.optional(
|
||||||
|
v.union(v.literal("inbound"), v.literal("outbound"), v.literal("system"))
|
||||||
|
),
|
||||||
|
channel: v.union(
|
||||||
|
v.literal("call"),
|
||||||
|
v.literal("sms"),
|
||||||
|
v.literal("chat"),
|
||||||
|
v.literal("unknown")
|
||||||
|
),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
messageType: v.optional(v.string()),
|
||||||
|
body: v.string(),
|
||||||
|
status: v.optional(v.string()),
|
||||||
|
sentAt: v.number(),
|
||||||
|
ghlMessageId: v.optional(v.string()),
|
||||||
|
voiceTranscriptTurnId: v.optional(v.id("voiceTranscriptTurns")),
|
||||||
|
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||||
|
livekitRoomName: v.optional(v.string()),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_conversationId", ["conversationId"])
|
||||||
|
.index("by_contactId", ["contactId"])
|
||||||
|
.index("by_ghlMessageId", ["ghlMessageId"])
|
||||||
|
.index("by_voiceTranscriptTurnId", ["voiceTranscriptTurnId"])
|
||||||
|
.index("by_sentAt", ["sentAt"]),
|
||||||
|
|
||||||
|
callArtifacts: defineTable({
|
||||||
|
conversationId: v.id("conversations"),
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
source: v.optional(v.string()),
|
||||||
|
recordingId: v.optional(v.string()),
|
||||||
|
recordingUrl: v.optional(v.string()),
|
||||||
|
recordingStatus: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("pending"),
|
||||||
|
v.literal("starting"),
|
||||||
|
v.literal("recording"),
|
||||||
|
v.literal("completed"),
|
||||||
|
v.literal("failed")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
transcriptionText: v.optional(v.string()),
|
||||||
|
durationMs: v.optional(v.number()),
|
||||||
|
startedAt: v.optional(v.number()),
|
||||||
|
endedAt: v.optional(v.number()),
|
||||||
|
ghlMessageId: v.optional(v.string()),
|
||||||
|
voiceSessionId: v.optional(v.id("voiceSessions")),
|
||||||
|
livekitRoomName: v.optional(v.string()),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_conversationId", ["conversationId"])
|
||||||
|
.index("by_contactId", ["contactId"])
|
||||||
|
.index("by_recordingId", ["recordingId"])
|
||||||
|
.index("by_voiceSessionId", ["voiceSessionId"])
|
||||||
|
.index("by_ghlMessageId", ["ghlMessageId"]),
|
||||||
|
|
||||||
|
externalSyncState: defineTable({
|
||||||
|
provider: v.string(),
|
||||||
|
entityType: v.string(),
|
||||||
|
entityId: v.string(),
|
||||||
|
cursor: v.optional(v.string()),
|
||||||
|
checksum: v.optional(v.string()),
|
||||||
|
status: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("pending"),
|
||||||
|
v.literal("synced"),
|
||||||
|
v.literal("failed"),
|
||||||
|
v.literal("reconciled"),
|
||||||
|
v.literal("mismatch")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
lastAttemptAt: v.optional(v.number()),
|
||||||
|
lastSyncedAt: v.optional(v.number()),
|
||||||
|
error: v.optional(v.string()),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_provider_entityType", ["provider", "entityType"])
|
||||||
|
.index("by_provider_entityType_entityId", ["provider", "entityType", "entityId"]),
|
||||||
|
|
||||||
voiceSessions: defineTable({
|
voiceSessions: defineTable({
|
||||||
roomName: v.string(),
|
roomName: v.string(),
|
||||||
participantIdentity: v.string(),
|
participantIdentity: v.string(),
|
||||||
callerPhone: v.optional(v.string()),
|
|
||||||
siteUrl: v.optional(v.string()),
|
siteUrl: v.optional(v.string()),
|
||||||
pathname: v.optional(v.string()),
|
pathname: v.optional(v.string()),
|
||||||
pageUrl: v.optional(v.string()),
|
pageUrl: v.optional(v.string()),
|
||||||
source: v.optional(v.string()),
|
source: v.optional(v.string()),
|
||||||
contactProfileId: v.optional(v.id("contactProfiles")),
|
|
||||||
contactDisplayName: v.optional(v.string()),
|
|
||||||
contactCompany: v.optional(v.string()),
|
|
||||||
startedAt: v.number(),
|
startedAt: v.number(),
|
||||||
endedAt: v.optional(v.number()),
|
endedAt: v.optional(v.number()),
|
||||||
callStatus: v.optional(
|
callStatus: v.optional(
|
||||||
|
|
@ -310,28 +458,6 @@ export default defineSchema({
|
||||||
),
|
),
|
||||||
notificationSentAt: v.optional(v.number()),
|
notificationSentAt: v.optional(v.number()),
|
||||||
notificationError: v.optional(v.string()),
|
notificationError: v.optional(v.string()),
|
||||||
reminderStatus: v.optional(
|
|
||||||
v.union(v.literal("none"), v.literal("scheduled"), v.literal("sameDay"))
|
|
||||||
),
|
|
||||||
reminderRequestedAt: v.optional(v.number()),
|
|
||||||
reminderStartAt: v.optional(v.number()),
|
|
||||||
reminderEndAt: v.optional(v.number()),
|
|
||||||
reminderCalendarEventId: v.optional(v.string()),
|
|
||||||
reminderCalendarHtmlLink: v.optional(v.string()),
|
|
||||||
reminderNote: v.optional(v.string()),
|
|
||||||
warmTransferStatus: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal("none"),
|
|
||||||
v.literal("attempted"),
|
|
||||||
v.literal("connected"),
|
|
||||||
v.literal("failed"),
|
|
||||||
v.literal("fallback")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
warmTransferTarget: v.optional(v.string()),
|
|
||||||
warmTransferAttemptedAt: v.optional(v.number()),
|
|
||||||
warmTransferConnectedAt: v.optional(v.number()),
|
|
||||||
warmTransferFailureReason: v.optional(v.string()),
|
|
||||||
recordingDisclosureAt: v.optional(v.number()),
|
recordingDisclosureAt: v.optional(v.number()),
|
||||||
recordingStatus: v.optional(
|
recordingStatus: v.optional(
|
||||||
v.union(
|
v.union(
|
||||||
|
|
@ -346,12 +472,13 @@ export default defineSchema({
|
||||||
recordingUrl: v.optional(v.string()),
|
recordingUrl: v.optional(v.string()),
|
||||||
recordingError: v.optional(v.string()),
|
recordingError: v.optional(v.string()),
|
||||||
metadata: v.optional(v.string()),
|
metadata: v.optional(v.string()),
|
||||||
|
contactId: v.optional(v.id("contacts")),
|
||||||
|
conversationId: v.optional(v.id("conversations")),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
})
|
})
|
||||||
.index("by_roomName", ["roomName"])
|
.index("by_roomName", ["roomName"])
|
||||||
.index("by_participantIdentity", ["participantIdentity"])
|
.index("by_participantIdentity", ["participantIdentity"])
|
||||||
.index("by_callerPhone", ["callerPhone"])
|
|
||||||
.index("by_source", ["source"])
|
.index("by_source", ["source"])
|
||||||
.index("by_source_startedAt", ["source", "startedAt"])
|
.index("by_source_startedAt", ["source", "startedAt"])
|
||||||
.index("by_startedAt", ["startedAt"]),
|
.index("by_startedAt", ["startedAt"]),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,68 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { mutation, query } from "./_generated/server"
|
import { mutation, query } from "./_generated/server"
|
||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
|
import {
|
||||||
|
ensureConversationParticipant,
|
||||||
|
upsertCallArtifactRecord,
|
||||||
|
upsertContactRecord,
|
||||||
|
upsertConversationRecord,
|
||||||
|
upsertMessageRecord,
|
||||||
|
} from "./crmModel"
|
||||||
|
|
||||||
|
async function syncPhoneConversation(ctx, session, overrides = {}) {
|
||||||
|
const contact = await upsertContactRecord(ctx, {
|
||||||
|
firstName: "Phone",
|
||||||
|
lastName: "Caller",
|
||||||
|
phone: session.participantIdentity,
|
||||||
|
livekitIdentity: session.participantIdentity,
|
||||||
|
source: "phone-agent",
|
||||||
|
status: "lead",
|
||||||
|
lastActivityAt:
|
||||||
|
overrides.lastActivityAt ?? session.updatedAt ?? session.startedAt ?? Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const conversation = await upsertConversationRecord(ctx, {
|
||||||
|
contactId: contact?._id,
|
||||||
|
title: `Phone call ${session.roomName}`,
|
||||||
|
channel: "call",
|
||||||
|
source: "phone-agent",
|
||||||
|
status:
|
||||||
|
session.callStatus === "completed"
|
||||||
|
? "closed"
|
||||||
|
: session.callStatus === "failed"
|
||||||
|
? "archived"
|
||||||
|
: "open",
|
||||||
|
direction: "inbound",
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
endedAt: session.endedAt,
|
||||||
|
lastMessageAt: overrides.lastActivityAt ?? session.updatedAt ?? session.startedAt,
|
||||||
|
lastMessagePreview:
|
||||||
|
overrides.lastMessagePreview ?? session.summaryText ?? session.handoffReason,
|
||||||
|
summaryText: session.summaryText,
|
||||||
|
livekitRoomName: session.roomName,
|
||||||
|
voiceSessionId: session._id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ensureConversationParticipant(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
role: "contact",
|
||||||
|
displayName: contact ? `${contact.firstName} ${contact.lastName}`.trim() : "Phone caller",
|
||||||
|
phone: contact?.phone || session.participantIdentity,
|
||||||
|
email: contact?.email,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.db.patch(session._id, {
|
||||||
|
contactId: contact?._id,
|
||||||
|
conversationId: conversation?._id,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
contact,
|
||||||
|
conversation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getByRoom = query({
|
export const getByRoom = query({
|
||||||
args: {
|
args: {
|
||||||
|
|
@ -61,15 +123,11 @@ export const createSession = mutation({
|
||||||
args: {
|
args: {
|
||||||
roomName: v.string(),
|
roomName: v.string(),
|
||||||
participantIdentity: v.string(),
|
participantIdentity: v.string(),
|
||||||
callerPhone: v.optional(v.string()),
|
|
||||||
siteUrl: v.optional(v.string()),
|
siteUrl: v.optional(v.string()),
|
||||||
pathname: v.optional(v.string()),
|
pathname: v.optional(v.string()),
|
||||||
pageUrl: v.optional(v.string()),
|
pageUrl: v.optional(v.string()),
|
||||||
source: v.optional(v.string()),
|
source: v.optional(v.string()),
|
||||||
metadata: v.optional(v.string()),
|
metadata: v.optional(v.string()),
|
||||||
contactProfileId: v.optional(v.id("contactProfiles")),
|
|
||||||
contactDisplayName: v.optional(v.string()),
|
|
||||||
contactCompany: v.optional(v.string()),
|
|
||||||
startedAt: v.optional(v.number()),
|
startedAt: v.optional(v.number()),
|
||||||
recordingDisclosureAt: v.optional(v.number()),
|
recordingDisclosureAt: v.optional(v.number()),
|
||||||
callStatus: v.optional(
|
callStatus: v.optional(
|
||||||
|
|
@ -87,7 +145,7 @@ export const createSession = mutation({
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const now = args.startedAt ?? Date.now()
|
const now = args.startedAt ?? Date.now()
|
||||||
return await ctx.db.insert("voiceSessions", {
|
const id = await ctx.db.insert("voiceSessions", {
|
||||||
...args,
|
...args,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
callStatus: args.callStatus,
|
callStatus: args.callStatus,
|
||||||
|
|
@ -95,11 +153,16 @@ export const createSession = mutation({
|
||||||
leadOutcome: "none",
|
leadOutcome: "none",
|
||||||
handoffRequested: false,
|
handoffRequested: false,
|
||||||
notificationStatus: "pending",
|
notificationStatus: "pending",
|
||||||
reminderStatus: "none",
|
|
||||||
warmTransferStatus: "none",
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const session = await ctx.db.get(id)
|
||||||
|
if (session) {
|
||||||
|
await syncPhoneConversation(ctx, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -107,15 +170,11 @@ export const upsertPhoneCallSession = mutation({
|
||||||
args: {
|
args: {
|
||||||
roomName: v.string(),
|
roomName: v.string(),
|
||||||
participantIdentity: v.string(),
|
participantIdentity: v.string(),
|
||||||
callerPhone: v.optional(v.string()),
|
|
||||||
siteUrl: v.optional(v.string()),
|
siteUrl: v.optional(v.string()),
|
||||||
pathname: v.optional(v.string()),
|
pathname: v.optional(v.string()),
|
||||||
pageUrl: v.optional(v.string()),
|
pageUrl: v.optional(v.string()),
|
||||||
source: v.optional(v.string()),
|
source: v.optional(v.string()),
|
||||||
metadata: v.optional(v.string()),
|
metadata: v.optional(v.string()),
|
||||||
contactProfileId: v.optional(v.id("contactProfiles")),
|
|
||||||
contactDisplayName: v.optional(v.string()),
|
|
||||||
contactCompany: v.optional(v.string()),
|
|
||||||
startedAt: v.optional(v.number()),
|
startedAt: v.optional(v.number()),
|
||||||
recordingDisclosureAt: v.optional(v.number()),
|
recordingDisclosureAt: v.optional(v.number()),
|
||||||
recordingStatus: v.optional(
|
recordingStatus: v.optional(
|
||||||
|
|
@ -138,41 +197,34 @@ export const upsertPhoneCallSession = mutation({
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await ctx.db.patch(existing._id, {
|
await ctx.db.patch(existing._id, {
|
||||||
participantIdentity: args.participantIdentity,
|
participantIdentity: args.participantIdentity,
|
||||||
callerPhone: args.callerPhone || existing.callerPhone,
|
|
||||||
siteUrl: args.siteUrl,
|
siteUrl: args.siteUrl,
|
||||||
pathname: args.pathname,
|
pathname: args.pathname,
|
||||||
pageUrl: args.pageUrl,
|
pageUrl: args.pageUrl,
|
||||||
source: args.source,
|
source: args.source,
|
||||||
metadata: args.metadata,
|
metadata: args.metadata,
|
||||||
contactProfileId: args.contactProfileId || existing.contactProfileId,
|
|
||||||
contactDisplayName:
|
|
||||||
args.contactDisplayName || existing.contactDisplayName,
|
|
||||||
contactCompany: args.contactCompany || existing.contactCompany,
|
|
||||||
startedAt: existing.startedAt || now,
|
startedAt: existing.startedAt || now,
|
||||||
recordingDisclosureAt:
|
recordingDisclosureAt:
|
||||||
args.recordingDisclosureAt ?? existing.recordingDisclosureAt,
|
args.recordingDisclosureAt ?? existing.recordingDisclosureAt,
|
||||||
recordingStatus: args.recordingStatus ?? existing.recordingStatus,
|
recordingStatus: args.recordingStatus ?? existing.recordingStatus,
|
||||||
callStatus: existing.callStatus || "started",
|
callStatus: existing.callStatus || "started",
|
||||||
notificationStatus: existing.notificationStatus || "pending",
|
notificationStatus: existing.notificationStatus || "pending",
|
||||||
reminderStatus: existing.reminderStatus || "none",
|
|
||||||
warmTransferStatus: existing.warmTransferStatus || "none",
|
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
return await ctx.db.get(existing._id)
|
const updated = await ctx.db.get(existing._id)
|
||||||
|
if (updated) {
|
||||||
|
await syncPhoneConversation(ctx, updated)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = await ctx.db.insert("voiceSessions", {
|
const id = await ctx.db.insert("voiceSessions", {
|
||||||
roomName: args.roomName,
|
roomName: args.roomName,
|
||||||
participantIdentity: args.participantIdentity,
|
participantIdentity: args.participantIdentity,
|
||||||
callerPhone: args.callerPhone,
|
|
||||||
siteUrl: args.siteUrl,
|
siteUrl: args.siteUrl,
|
||||||
pathname: args.pathname,
|
pathname: args.pathname,
|
||||||
pageUrl: args.pageUrl,
|
pageUrl: args.pageUrl,
|
||||||
source: args.source,
|
source: args.source,
|
||||||
metadata: args.metadata,
|
metadata: args.metadata,
|
||||||
contactProfileId: args.contactProfileId,
|
|
||||||
contactDisplayName: args.contactDisplayName,
|
|
||||||
contactCompany: args.contactCompany,
|
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
recordingDisclosureAt: args.recordingDisclosureAt,
|
recordingDisclosureAt: args.recordingDisclosureAt,
|
||||||
recordingStatus: args.recordingStatus,
|
recordingStatus: args.recordingStatus,
|
||||||
|
|
@ -181,13 +233,15 @@ export const upsertPhoneCallSession = mutation({
|
||||||
leadOutcome: "none",
|
leadOutcome: "none",
|
||||||
handoffRequested: false,
|
handoffRequested: false,
|
||||||
notificationStatus: "pending",
|
notificationStatus: "pending",
|
||||||
reminderStatus: "none",
|
|
||||||
warmTransferStatus: "none",
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
return await ctx.db.get(id)
|
const session = await ctx.db.get(id)
|
||||||
|
if (session) {
|
||||||
|
await syncPhoneConversation(ctx, session)
|
||||||
|
}
|
||||||
|
return session
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -226,6 +280,33 @@ export const addTranscriptTurn = mutation({
|
||||||
: session.agentAnsweredAt,
|
: session.agentAnsweredAt,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { contact, conversation } = await syncPhoneConversation(ctx, {
|
||||||
|
...session,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
}, {
|
||||||
|
lastActivityAt: createdAt,
|
||||||
|
lastMessagePreview: args.text,
|
||||||
|
})
|
||||||
|
|
||||||
|
await upsertMessageRecord(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: args.role === "user" ? contact?._id : undefined,
|
||||||
|
direction:
|
||||||
|
args.role === "user"
|
||||||
|
? "inbound"
|
||||||
|
: args.role === "assistant"
|
||||||
|
? "outbound"
|
||||||
|
: "system",
|
||||||
|
channel: "call",
|
||||||
|
source: args.source || "phone-agent",
|
||||||
|
messageType: args.kind || "transcript",
|
||||||
|
body: args.text,
|
||||||
|
sentAt: createdAt,
|
||||||
|
voiceTranscriptTurnId: turnId,
|
||||||
|
voiceSessionId: args.sessionId,
|
||||||
|
livekitRoomName: args.roomName,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return turnId
|
return turnId
|
||||||
|
|
@ -236,9 +317,6 @@ export const linkPhoneCallLead = mutation({
|
||||||
args: {
|
args: {
|
||||||
sessionId: v.id("voiceSessions"),
|
sessionId: v.id("voiceSessions"),
|
||||||
linkedLeadId: v.optional(v.string()),
|
linkedLeadId: v.optional(v.string()),
|
||||||
contactProfileId: v.optional(v.id("contactProfiles")),
|
|
||||||
contactDisplayName: v.optional(v.string()),
|
|
||||||
contactCompany: v.optional(v.string()),
|
|
||||||
leadOutcome: v.optional(
|
leadOutcome: v.optional(
|
||||||
v.union(
|
v.union(
|
||||||
v.literal("none"),
|
v.literal("none"),
|
||||||
|
|
@ -248,65 +326,30 @@ export const linkPhoneCallLead = mutation({
|
||||||
),
|
),
|
||||||
handoffRequested: v.optional(v.boolean()),
|
handoffRequested: v.optional(v.boolean()),
|
||||||
handoffReason: v.optional(v.string()),
|
handoffReason: v.optional(v.string()),
|
||||||
reminderStatus: v.optional(
|
|
||||||
v.union(v.literal("none"), v.literal("scheduled"), v.literal("sameDay"))
|
|
||||||
),
|
|
||||||
reminderRequestedAt: v.optional(v.number()),
|
|
||||||
reminderStartAt: v.optional(v.number()),
|
|
||||||
reminderEndAt: v.optional(v.number()),
|
|
||||||
reminderCalendarEventId: v.optional(v.string()),
|
|
||||||
reminderCalendarHtmlLink: v.optional(v.string()),
|
|
||||||
reminderNote: v.optional(v.string()),
|
|
||||||
warmTransferStatus: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal("none"),
|
|
||||||
v.literal("attempted"),
|
|
||||||
v.literal("connected"),
|
|
||||||
v.literal("failed"),
|
|
||||||
v.literal("fallback")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
warmTransferTarget: v.optional(v.string()),
|
|
||||||
warmTransferAttemptedAt: v.optional(v.number()),
|
|
||||||
warmTransferConnectedAt: v.optional(v.number()),
|
|
||||||
warmTransferFailureReason: v.optional(v.string()),
|
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const patch: Record<string, unknown> = {
|
await ctx.db.patch(args.sessionId, {
|
||||||
updatedAt: Date.now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionalEntries = {
|
|
||||||
linkedLeadId: args.linkedLeadId,
|
linkedLeadId: args.linkedLeadId,
|
||||||
contactProfileId: args.contactProfileId,
|
|
||||||
contactDisplayName: args.contactDisplayName,
|
|
||||||
contactCompany: args.contactCompany,
|
|
||||||
leadOutcome: args.leadOutcome,
|
leadOutcome: args.leadOutcome,
|
||||||
handoffRequested: args.handoffRequested,
|
handoffRequested: args.handoffRequested,
|
||||||
handoffReason: args.handoffReason,
|
handoffReason: args.handoffReason,
|
||||||
reminderStatus: args.reminderStatus,
|
updatedAt: Date.now(),
|
||||||
reminderRequestedAt: args.reminderRequestedAt,
|
})
|
||||||
reminderStartAt: args.reminderStartAt,
|
const session = await ctx.db.get(args.sessionId)
|
||||||
reminderEndAt: args.reminderEndAt,
|
if (session) {
|
||||||
reminderCalendarEventId: args.reminderCalendarEventId,
|
const { conversation } = await syncPhoneConversation(ctx, session)
|
||||||
reminderCalendarHtmlLink: args.reminderCalendarHtmlLink,
|
if (args.linkedLeadId || args.leadOutcome || args.handoffReason) {
|
||||||
reminderNote: args.reminderNote,
|
await ctx.db.patch(conversation._id, {
|
||||||
warmTransferStatus: args.warmTransferStatus,
|
summaryText:
|
||||||
warmTransferTarget: args.warmTransferTarget,
|
session.summaryText ||
|
||||||
warmTransferAttemptedAt: args.warmTransferAttemptedAt,
|
args.handoffReason ||
|
||||||
warmTransferConnectedAt: args.warmTransferConnectedAt,
|
conversation.summaryText,
|
||||||
warmTransferFailureReason: args.warmTransferFailureReason,
|
updatedAt: Date.now(),
|
||||||
}
|
})
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(optionalEntries)) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
patch[key] = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.patch(args.sessionId, patch)
|
return session
|
||||||
|
|
||||||
return await ctx.db.get(args.sessionId)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -334,7 +377,21 @@ export const updateRecording = mutation({
|
||||||
recordingError: args.recordingError,
|
recordingError: args.recordingError,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
return await ctx.db.get(args.sessionId)
|
const session = await ctx.db.get(args.sessionId)
|
||||||
|
if (session) {
|
||||||
|
const { contact, conversation } = await syncPhoneConversation(ctx, session)
|
||||||
|
await upsertCallArtifactRecord(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
source: "phone-agent",
|
||||||
|
recordingId: args.recordingId,
|
||||||
|
recordingUrl: args.recordingUrl,
|
||||||
|
recordingStatus: args.recordingStatus,
|
||||||
|
voiceSessionId: session._id,
|
||||||
|
livekitRoomName: session.roomName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return session
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -384,7 +441,31 @@ export const completeSession = mutation({
|
||||||
notificationError: args.notificationError,
|
notificationError: args.notificationError,
|
||||||
updatedAt: endedAt,
|
updatedAt: endedAt,
|
||||||
})
|
})
|
||||||
return await ctx.db.get(args.sessionId)
|
const session = await ctx.db.get(args.sessionId)
|
||||||
|
if (session) {
|
||||||
|
const { contact, conversation } = await syncPhoneConversation(ctx, session, {
|
||||||
|
lastActivityAt: endedAt,
|
||||||
|
lastMessagePreview: args.summaryText || session.summaryText,
|
||||||
|
})
|
||||||
|
await upsertCallArtifactRecord(ctx, {
|
||||||
|
conversationId: conversation._id,
|
||||||
|
contactId: contact?._id,
|
||||||
|
source: "phone-agent",
|
||||||
|
recordingId: args.recordingId,
|
||||||
|
recordingUrl: args.recordingUrl,
|
||||||
|
recordingStatus: args.recordingStatus,
|
||||||
|
transcriptionText: args.summaryText,
|
||||||
|
durationMs:
|
||||||
|
typeof session.startedAt === "number"
|
||||||
|
? Math.max(0, endedAt - session.startedAt)
|
||||||
|
: undefined,
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
endedAt,
|
||||||
|
voiceSessionId: session._id,
|
||||||
|
livekitRoomName: session.roomName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return session
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -398,13 +479,9 @@ function normalizePhoneCallForAdmin(session: any) {
|
||||||
id: session._id,
|
id: session._id,
|
||||||
roomName: session.roomName,
|
roomName: session.roomName,
|
||||||
participantIdentity: session.participantIdentity,
|
participantIdentity: session.participantIdentity,
|
||||||
callerPhone: session.callerPhone,
|
|
||||||
pathname: session.pathname,
|
pathname: session.pathname,
|
||||||
pageUrl: session.pageUrl,
|
pageUrl: session.pageUrl,
|
||||||
source: session.source,
|
source: session.source,
|
||||||
contactProfileId: session.contactProfileId,
|
|
||||||
contactDisplayName: session.contactDisplayName,
|
|
||||||
contactCompany: session.contactCompany,
|
|
||||||
startedAt: session.startedAt,
|
startedAt: session.startedAt,
|
||||||
endedAt: session.endedAt,
|
endedAt: session.endedAt,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
|
@ -420,91 +497,12 @@ function normalizePhoneCallForAdmin(session: any) {
|
||||||
notificationStatus: session.notificationStatus || "pending",
|
notificationStatus: session.notificationStatus || "pending",
|
||||||
notificationSentAt: session.notificationSentAt,
|
notificationSentAt: session.notificationSentAt,
|
||||||
notificationError: session.notificationError,
|
notificationError: session.notificationError,
|
||||||
reminderStatus: session.reminderStatus || "none",
|
|
||||||
reminderRequestedAt: session.reminderRequestedAt,
|
|
||||||
reminderStartAt: session.reminderStartAt,
|
|
||||||
reminderEndAt: session.reminderEndAt,
|
|
||||||
reminderCalendarEventId: session.reminderCalendarEventId,
|
|
||||||
reminderCalendarHtmlLink: session.reminderCalendarHtmlLink,
|
|
||||||
reminderNote: session.reminderNote,
|
|
||||||
warmTransferStatus: session.warmTransferStatus || "none",
|
|
||||||
warmTransferTarget: session.warmTransferTarget,
|
|
||||||
warmTransferAttemptedAt: session.warmTransferAttemptedAt,
|
|
||||||
warmTransferConnectedAt: session.warmTransferConnectedAt,
|
|
||||||
warmTransferFailureReason: session.warmTransferFailureReason,
|
|
||||||
recordingStatus: session.recordingStatus,
|
recordingStatus: session.recordingStatus,
|
||||||
recordingUrl: session.recordingUrl,
|
recordingUrl: session.recordingUrl,
|
||||||
recordingError: session.recordingError,
|
recordingError: session.recordingError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPhoneAgentContextByPhone = query({
|
|
||||||
args: {
|
|
||||||
normalizedPhone: v.string(),
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const contactProfile = await ctx.db
|
|
||||||
.query("contactProfiles")
|
|
||||||
.withIndex("by_normalizedPhone", (q) =>
|
|
||||||
q.eq("normalizedPhone", args.normalizedPhone)
|
|
||||||
)
|
|
||||||
.unique()
|
|
||||||
|
|
||||||
const recentSessions = await ctx.db
|
|
||||||
.query("voiceSessions")
|
|
||||||
.withIndex("by_callerPhone", (q) => q.eq("callerPhone", args.normalizedPhone))
|
|
||||||
.collect()
|
|
||||||
|
|
||||||
recentSessions.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
|
|
||||||
const recentSession = recentSessions[0] || null
|
|
||||||
|
|
||||||
const recentLead = await ctx.db
|
|
||||||
.query("leadSubmissions")
|
|
||||||
.withIndex("by_normalizedPhone", (q) =>
|
|
||||||
q.eq("normalizedPhone", args.normalizedPhone)
|
|
||||||
)
|
|
||||||
.collect()
|
|
||||||
|
|
||||||
recentLead.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
|
|
||||||
const latestLead = recentLead[0] || null
|
|
||||||
|
|
||||||
return {
|
|
||||||
contactProfile: contactProfile
|
|
||||||
? {
|
|
||||||
id: contactProfile._id,
|
|
||||||
normalizedPhone: contactProfile.normalizedPhone,
|
|
||||||
displayName: contactProfile.displayName,
|
|
||||||
firstName: contactProfile.firstName,
|
|
||||||
lastName: contactProfile.lastName,
|
|
||||||
email: contactProfile.email,
|
|
||||||
company: contactProfile.company,
|
|
||||||
lastIntent: contactProfile.lastIntent,
|
|
||||||
lastLeadOutcome: contactProfile.lastLeadOutcome,
|
|
||||||
lastSummaryText: contactProfile.lastSummaryText,
|
|
||||||
lastCallAt: contactProfile.lastCallAt,
|
|
||||||
lastReminderAt: contactProfile.lastReminderAt,
|
|
||||||
reminderNotes: contactProfile.reminderNotes,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
recentSession: recentSession ? normalizePhoneCallForAdmin(recentSession) : null,
|
|
||||||
recentLead: latestLead
|
|
||||||
? {
|
|
||||||
id: latestLead._id,
|
|
||||||
type: latestLead.type,
|
|
||||||
firstName: latestLead.firstName,
|
|
||||||
lastName: latestLead.lastName,
|
|
||||||
email: latestLead.email,
|
|
||||||
phone: latestLead.phone,
|
|
||||||
company: latestLead.company,
|
|
||||||
intent: latestLead.intent,
|
|
||||||
message: latestLead.message,
|
|
||||||
createdAt: latestLead.createdAt,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const listAdminPhoneCalls = query({
|
export const listAdminPhoneCalls = query({
|
||||||
args: {
|
args: {
|
||||||
search: v.optional(v.string()),
|
search: v.optional(v.string()),
|
||||||
|
|
@ -538,15 +536,10 @@ export const listAdminPhoneCalls = query({
|
||||||
const haystack = [
|
const haystack = [
|
||||||
session.roomName,
|
session.roomName,
|
||||||
session.participantIdentity,
|
session.participantIdentity,
|
||||||
session.callerPhone,
|
|
||||||
session.contactDisplayName,
|
|
||||||
session.contactCompany,
|
|
||||||
session.pathname,
|
session.pathname,
|
||||||
session.linkedLeadId,
|
session.linkedLeadId,
|
||||||
session.summaryText,
|
session.summaryText,
|
||||||
session.handoffReason,
|
session.handoffReason,
|
||||||
session.reminderNote,
|
|
||||||
session.warmTransferFailureReason,
|
|
||||||
]
|
]
|
||||||
.map((value) => String(value || "").toLowerCase())
|
.map((value) => String(value || "").toLowerCase())
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|
@ -619,9 +612,6 @@ export const getAdminPhoneCallDetail = query({
|
||||||
createdAt: linkedLead.createdAt,
|
createdAt: linkedLead.createdAt,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
contactProfile: session.contactProfileId
|
|
||||||
? await ctx.db.get(session.contactProfileId)
|
|
||||||
: null,
|
|
||||||
turns: turns.map((turn) => ({
|
turns: turns.map((turn) => ({
|
||||||
id: turn._id,
|
id: turn._id,
|
||||||
role: turn.role,
|
role: turn.role,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
|
import { createHash, randomBytes } from "node:crypto"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { fetchMutation, fetchQuery } from "convex/nextjs"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { hasConvexUrl } from "@/lib/convex-config"
|
||||||
|
|
||||||
|
export const ADMIN_SESSION_COOKIE = "rmv_admin_session"
|
||||||
|
const ADMIN_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7
|
||||||
|
|
||||||
function getProvidedToken(request: Request) {
|
function getProvidedToken(request: Request) {
|
||||||
const authHeader = request.headers.get("authorization") || ""
|
const authHeader = request.headers.get("authorization") || ""
|
||||||
|
|
@ -30,3 +38,100 @@ export function requireAdminToken(request: Request) {
|
||||||
export function isAdminUiEnabled() {
|
export function isAdminUiEnabled() {
|
||||||
return process.env.ADMIN_UI_ENABLED === "true"
|
return process.env.ADMIN_UI_ENABLED === "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getConfiguredAdminEmail() {
|
||||||
|
return String(process.env.ADMIN_EMAIL || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredAdminPassword() {
|
||||||
|
return String(process.env.ADMIN_PASSWORD || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashAdminSessionToken(token: string) {
|
||||||
|
return createHash("sha256").update(token).digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminCredentialLoginConfigured() {
|
||||||
|
return Boolean(
|
||||||
|
isAdminUiEnabled() &&
|
||||||
|
hasConvexUrl() &&
|
||||||
|
getConfiguredAdminEmail() &&
|
||||||
|
getConfiguredAdminPassword()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminCredentialMatch(email: string, password: string) {
|
||||||
|
return (
|
||||||
|
email.trim().toLowerCase() === getConfiguredAdminEmail() &&
|
||||||
|
password === getConfiguredAdminPassword()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminSession(email: string) {
|
||||||
|
if (!hasConvexUrl()) {
|
||||||
|
throw new Error("Convex is not configured for admin sessions.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.trim().toLowerCase()
|
||||||
|
const rawToken = randomBytes(32).toString("hex")
|
||||||
|
const tokenHash = hashAdminSessionToken(rawToken)
|
||||||
|
const expiresAt = Date.now() + ADMIN_SESSION_TTL_MS
|
||||||
|
|
||||||
|
await fetchMutation(api.admin.ensureAdminUser, {
|
||||||
|
email: normalizedEmail,
|
||||||
|
name: normalizedEmail.split("@")[0],
|
||||||
|
})
|
||||||
|
|
||||||
|
await fetchMutation(api.admin.createSession, {
|
||||||
|
email: normalizedEmail,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: rawToken,
|
||||||
|
expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyAdminSession(rawToken?: string | null) {
|
||||||
|
if (!rawToken || !hasConvexUrl()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchMutation(api.admin.destroySession, {
|
||||||
|
tokenHash: hashAdminSessionToken(rawToken),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to destroy admin session:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateAdminSession(rawToken?: string | null) {
|
||||||
|
if (!rawToken || !hasConvexUrl()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetchQuery(api.admin.validateSession, {
|
||||||
|
tokenHash: hashAdminSessionToken(rawToken),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to validate admin session:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminUserFromCookies() {
|
||||||
|
if (!isAdminUiEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value
|
||||||
|
const session = await validateAdminSession(rawToken)
|
||||||
|
return session?.user || null
|
||||||
|
}
|
||||||
|
|
|
||||||
130
lib/server/ghl-sync.ts
Normal file
130
lib/server/ghl-sync.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
type GhlSyncEnv = {
|
||||||
|
token: string
|
||||||
|
locationId: string
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value?: string) {
|
||||||
|
return (value || "https://services.leadconnectorhq.com").replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGhlSyncEnv(): GhlSyncEnv {
|
||||||
|
const token = String(
|
||||||
|
process.env.GHL_PRIVATE_INTEGRATION_TOKEN || process.env.GHL_API_TOKEN || ""
|
||||||
|
).trim()
|
||||||
|
const locationId = String(process.env.GHL_LOCATION_ID || "").trim()
|
||||||
|
const baseUrl = normalizeBaseUrl(process.env.GHL_API_BASE_URL)
|
||||||
|
|
||||||
|
if (!token || !locationId) {
|
||||||
|
throw new Error("GHL token or location ID is not configured.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, locationId, baseUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGhlJson(pathname: string, init?: RequestInit) {
|
||||||
|
const env = getGhlSyncEnv()
|
||||||
|
const response = await fetch(`${env.baseUrl}${pathname}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.token}`,
|
||||||
|
Version: process.env.GHL_API_VERSION || "2021-07-28",
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
let body: any = null
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
body = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`GHL request failed (${response.status}) for ${pathname}: ${body?.message || text || "Unknown error"}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGhlContacts(args?: {
|
||||||
|
limit?: number
|
||||||
|
cursor?: string
|
||||||
|
}) {
|
||||||
|
const env = getGhlSyncEnv()
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
locationId: env.locationId,
|
||||||
|
limit: String(Math.min(100, Math.max(1, args?.limit || 100))),
|
||||||
|
})
|
||||||
|
if (args?.cursor) {
|
||||||
|
searchParams.set("startAfterId", args.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await fetchGhlJson(`/contacts/?${searchParams.toString()}`)
|
||||||
|
const contacts = Array.isArray(payload?.contacts)
|
||||||
|
? payload.contacts
|
||||||
|
: Array.isArray(payload?.data?.contacts)
|
||||||
|
? payload.data.contacts
|
||||||
|
: []
|
||||||
|
|
||||||
|
const nextCursor =
|
||||||
|
contacts.length > 0 ? String(contacts[contacts.length - 1]?.id || "") : ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: contacts,
|
||||||
|
nextCursor: nextCursor || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGhlMessages(args?: {
|
||||||
|
limit?: number
|
||||||
|
cursor?: string
|
||||||
|
channel?: "Call" | "SMS"
|
||||||
|
}) {
|
||||||
|
const env = getGhlSyncEnv()
|
||||||
|
const url = new URL(`${env.baseUrl}/conversations/messages/export`)
|
||||||
|
url.searchParams.set("locationId", env.locationId)
|
||||||
|
url.searchParams.set("limit", String(Math.min(100, Math.max(1, args?.limit || 100))))
|
||||||
|
url.searchParams.set("channel", args?.channel || "SMS")
|
||||||
|
if (args?.cursor) {
|
||||||
|
url.searchParams.set("cursor", args.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await fetchGhlJson(url.pathname + url.search)
|
||||||
|
return {
|
||||||
|
items: Array.isArray(payload?.messages) ? payload.messages : [],
|
||||||
|
nextCursor:
|
||||||
|
typeof payload?.nextCursor === "string" && payload.nextCursor
|
||||||
|
? payload.nextCursor
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGhlCallLogs(args?: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}) {
|
||||||
|
const env = getGhlSyncEnv()
|
||||||
|
const url = new URL(`${env.baseUrl}/voice-ai/dashboard/call-logs`)
|
||||||
|
url.searchParams.set("locationId", env.locationId)
|
||||||
|
url.searchParams.set("page", String(Math.max(1, args?.page || 1)))
|
||||||
|
url.searchParams.set(
|
||||||
|
"pageSize",
|
||||||
|
String(Math.min(50, Math.max(1, args?.pageSize || 50)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const payload = await fetchGhlJson(url.pathname + url.search)
|
||||||
|
return {
|
||||||
|
items: Array.isArray(payload?.callLogs) ? payload.callLogs : [],
|
||||||
|
page: Number(payload?.page || args?.page || 1),
|
||||||
|
total: Number(payload?.total || 0),
|
||||||
|
pageSize: Number(payload?.pageSize || args?.pageSize || 50),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue