feat: rebuild CRM inbox and contact mapping

This commit is contained in:
DMleadgen 2026-04-16 13:29:53 -06:00
parent 14cb8ce1fc
commit e294117e6e
Signed by: matt
GPG key ID: C2720CF8CD701894
6 changed files with 599 additions and 395 deletions

View file

@ -54,7 +54,7 @@ export default async function AdminContactDetailPage({ params }: PageProps) {
Back to contacts
</Link>
<h1 className="text-4xl font-bold tracking-tight text-balance">
{detail.contact.firstName} {detail.contact.lastName}
{detail.contact.displayName}
</h1>
<p className="text-muted-foreground">
Contact details and activity history.
@ -139,7 +139,7 @@ export default async function AdminContactDetailPage({ params }: PageProps) {
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">
{conversation.title || "Untitled conversation"}
{conversation.title || detail.contact.displayName}
</p>
<p className="text-xs text-muted-foreground">
{conversation.channel} {" "}

View file

@ -153,15 +153,17 @@ export default async function AdminContactsPage({ searchParams }: PageProps) {
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>
<div className="font-medium">{contact.displayName}</div>
{contact.email ? (
<div className="text-xs text-muted-foreground">
{contact.email}
</div>
) : null}
{contact.phone ? (
<div className="text-xs text-muted-foreground">
{contact.phone}
</div>
) : null}
</td>
<td className="py-3 pr-4">
{contact.company || "—"}

View file

@ -1,16 +1,4 @@
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"
import { redirect } from "next/navigation"
type PageProps = {
params: Promise<{
@ -18,220 +6,14 @@ type PageProps = {
}>
}
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({
export default async function AdminConversationDetailRedirect({
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">
Full conversation history in one place.
</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, contact, and latest activity.</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>Message 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>
)
redirect(`/admin/conversations?conversationId=${encodeURIComponent(id)}`)
}
export const metadata = {
title: "Conversation Detail | Admin",
description: "Review a conversation, recordings, and leads",
description: "Open a conversation in the inbox view",
}

View file

@ -1,6 +1,6 @@
import Link from "next/link"
import { fetchQuery } from "convex/nextjs"
import { MessageSquare, Search } from "lucide-react"
import { MessageSquare, Phone, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@ -12,12 +12,14 @@ import {
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
type PageProps = {
searchParams: Promise<{
search?: string
channel?: "call" | "sms" | "chat" | "unknown"
status?: "open" | "closed" | "archived"
conversationId?: string
page?: string
}>
}
@ -30,12 +32,42 @@ function formatTimestamp(value?: number) {
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
hour: "numeric",
minute: "2-digit",
})
}
function formatSidebarTimestamp(value?: number) {
if (!value) {
return ""
}
const date = new Date(value)
const now = new Date()
const sameDay = date.toDateString() === now.toDateString()
return sameDay
? date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})
: date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}
function formatDuration(value?: number) {
if (!value) {
return "—"
}
const totalSeconds = Math.max(0, Math.round(value / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${String(seconds).padStart(2, "0")}`
}
function getSyncMessage(sync: any) {
if (!sync.ghlConfigured) {
return "Connect GHL to load contacts and conversations."
@ -49,34 +81,99 @@ function getSyncMessage(sync: any) {
if (!sync.latestSyncAt) {
return "No conversations yet."
}
return "Calls and messages appear here as they are synced."
return "Browse contacts and conversations in one inbox."
}
function getInitials(value?: string) {
const text = String(value || "").trim()
if (!text) {
return "RM"
}
const parts = text.split(/\s+/).filter(Boolean)
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase()
}
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase()
}
function buildConversationHref(params: {
search?: string
channel?: string
status?: string
conversationId?: string
}) {
const nextParams = new URLSearchParams()
if (params.search) {
nextParams.set("search", params.search)
}
if (params.channel) {
nextParams.set("channel", params.channel)
}
if (params.status) {
nextParams.set("status", params.status)
}
if (params.conversationId) {
nextParams.set("conversationId", params.conversationId)
}
const query = nextParams.toString()
return query ? `/admin/conversations?${query}` : "/admin/conversations"
}
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,
page: 1,
limit: 100,
channel: params.channel,
status: params.status,
})
const selectedConversationId =
(params.conversationId &&
data.items.find((item: any) => item.id === params.conversationId)?.id) ||
data.items[0]?.id
const detail = selectedConversationId
? await fetchQuery(api.crm.getAdminConversationDetail, {
conversationId: selectedConversationId,
})
: null
const timeline = detail
? [
...detail.messages.map((message: any) => ({
id: `message-${message.id}`,
type: "message" as const,
timestamp: message.sentAt || 0,
message,
})),
...detail.recordings.map((recording: any) => ({
id: `recording-${recording.id}`,
type: "recording" as const,
timestamp: recording.startedAt || recording.endedAt || 0,
recording,
})),
].sort((a, b) => a.timestamp - b.timestamp)
: []
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-8">
<div className="space-y-6">
<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">
Customer conversations in one inbox.
Review calls and messages in one inbox.
</p>
</div>
<Link href="/admin">
@ -84,153 +181,273 @@ export default async function AdminConversationsPage({
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Sync Status</CardTitle>
<CardDescription>{getSyncMessage(data.sync)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<Card className="rounded-[2rem]">
<CardContent className="flex flex-wrap items-center gap-3 px-6 py-4 text-sm text-muted-foreground">
<Badge variant="outline">{data.sync.overallStatus}</Badge>
<span>
Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}
</span>
{!data.sync.ghlConfigured ? (
<span>GHL is not connected.</span>
) : null}
{data.sync.stages.conversations.error ? (
<span>{data.sync.stages.conversations.error}</span>
) : null}
<span>{getSyncMessage(data.sync)}</span>
<span>Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Conversation Inbox
</CardTitle>
<CardDescription>
Search by contact, phone, email, or recent message.
</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>
<Card className="overflow-hidden rounded-[2rem] p-0">
<div className="grid min-h-[720px] lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="border-b bg-white lg:border-b-0 lg:border-r">
<div className="space-y-4 border-b px-5 py-5">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="font-semibold">Conversation Inbox</h2>
<p className="text-sm text-muted-foreground">
Search and pick a conversation to review.
</p>
</div>
</div>
<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>
<form className="space-y-3">
<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 or messages"
className="pl-9"
/>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<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>
</div>
</form>
</div>
<ScrollArea className="h-[520px] lg:h-[640px]">
<div className="divide-y">
{data.items.length === 0 ? (
<tr>
<td
colSpan={8}
className="py-8 text-center text-muted-foreground"
>
{search || params.channel || params.status
? "No conversations matched this search."
: getSyncMessage(data.sync)}
</td>
</tr>
<div className="px-5 py-8 text-sm text-muted-foreground">
{search || params.channel || params.status
? "No conversations matched this search."
: getSyncMessage(data.sync)}
</div>
) : (
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"}
data.items.map((conversation: any) => {
const isSelected = conversation.id === selectedConversationId
return (
<Link
key={conversation.id}
href={buildConversationHref({
search,
channel: params.channel,
status: params.status,
conversationId: conversation.id,
})}
className={[
"flex gap-3 px-5 py-4 transition-colors",
isSelected
? "bg-primary/5 ring-1 ring-inset ring-primary/20"
: "hover:bg-muted/40",
].join(" ")}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{getInitials(conversation.displayName)}
</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 className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium">
{conversation.displayName}
</p>
{conversation.secondaryLine ? (
<p className="truncate text-xs text-muted-foreground">
{conversation.secondaryLine}
</p>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{conversation.contact.phone ||
conversation.contact.email ||
"—"}
<span className="shrink-0 text-xs text-muted-foreground">
{formatSidebarTimestamp(conversation.lastMessageAt)}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{conversation.lastMessagePreview ||
"No messages or call notes yet."}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge variant="outline">{conversation.channel}</Badge>
<Badge variant="secondary">{conversation.status}</Badge>
{conversation.recordingCount ? (
<Badge variant="outline">
{conversation.recordingCount} recording
{conversation.recordingCount === 1 ? "" : "s"}
</Badge>
) : null}
</div>
</div>
</Link>
)
})
)}
</div>
</ScrollArea>
</div>
<div className="bg-[#faf8f3]">
{detail ? (
<div className="flex h-full flex-col">
<div className="border-b bg-white px-6 py-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div>
<h2 className="text-2xl font-semibold">
{detail.contact?.name ||
detail.conversation.title ||
"Conversation"}
</h2>
{detail.contact?.secondaryLine ||
detail.contact?.email ||
detail.contact?.phone ? (
<p className="text-sm text-muted-foreground">
{detail.contact?.secondaryLine ||
detail.contact?.phone ||
detail.contact?.email}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
{detail.conversation.channel}
</Badge>
<Badge variant="secondary">
{detail.conversation.status}
</Badge>
<Badge variant="outline">
{timeline.filter((item) => item.type === "message").length}{" "}
messages
</Badge>
{detail.recordings.length ? (
<Badge variant="outline">
{detail.recordings.length} recording
{detail.recordings.length === 1 ? "" : "s"}
</Badge>
) : null}
</div>
</div>
<div className="text-sm text-muted-foreground">
Last activity:{" "}
{formatTimestamp(detail.conversation.lastMessageAt)}
</div>
</div>
</div>
<ScrollArea className="h-[520px] px-4 py-5 lg:h-[640px] lg:px-6">
<div className="space-y-4 pb-2">
{timeline.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white/70 px-6 py-10 text-center text-sm text-muted-foreground">
No messages or recordings have been mirrored into this
conversation yet.
</div>
) : (
timeline.map((item: any) => {
if (item.type === "recording") {
const recording = item.recording
return (
<div key={item.id} className="max-w-2xl rounded-2xl border bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Phone className="h-4 w-4 text-muted-foreground" />
Call recording
<Badge variant="outline" className="ml-2">
{recording.recordingStatus || "recording"}
</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
<span>{formatTimestamp(recording.startedAt)}</span>
<span>Duration: {formatDuration(recording.durationMs)}</span>
</div>
{recording.recordingUrl ? (
<div className="mt-3">
<a
href={recording.recordingUrl}
target="_blank"
className="text-sm font-medium text-primary hover:underline"
>
Open recording
</a>
</div>
) : null}
{recording.transcriptionText ? (
<div className="mt-3 rounded-xl border bg-muted/30 p-3 text-sm whitespace-pre-wrap text-foreground/90">
{recording.transcriptionText}
</div>
) : null}
</div>
)
}
const message = item.message
const isOutbound = message.direction === "outbound"
return (
<div
key={item.id}
className={`flex ${isOutbound ? "justify-end" : "justify-start"}`}
>
<div
className={[
"max-w-[85%] rounded-3xl px-4 py-3 shadow-sm",
isOutbound
? "bg-primary text-primary-foreground"
: "border bg-white",
].join(" ")}
>
<div className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-wide opacity-70">
<span>{message.channel}</span>
<span>{message.direction}</span>
{message.status ? <span>{message.status}</span> : null}
</div>
<p className="whitespace-pre-wrap text-sm leading-6">
{message.body}
</p>
<div className="mt-2 text-right text-xs opacity-70">
{formatTimestamp(message.sentAt)}
</div>
</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>
</ScrollArea>
</div>
) : (
<div className="flex h-full min-h-[520px] items-center justify-center px-6 py-16">
<div className="max-w-md text-center">
<h2 className="text-2xl font-semibold">No conversation selected</h2>
<p className="mt-2 text-sm text-muted-foreground">
Choose a conversation from the left to open the full thread.
</p>
</div>
</div>
)}
</div>
</CardContent>
</div>
</Card>
</div>
</div>

View file

@ -6,6 +6,7 @@ import {
ensureConversationParticipant,
normalizeEmail,
normalizePhone,
sanitizeContactNameParts,
upsertCallArtifactRecord,
upsertContactRecord,
upsertConversationRecord,
@ -229,6 +230,49 @@ function normalizeRecordingStatus(value?: string) {
return "pending"
}
function buildContactDisplay(contact?: {
firstName?: string
lastName?: string
email?: string
phone?: string
}) {
const firstName = String(contact?.firstName || "").trim()
const lastName = String(contact?.lastName || "").trim()
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim()
const displayName =
fullName || contact?.phone || contact?.email || "Unknown Contact"
const secondaryLine =
fullName && contact?.phone
? contact.phone
: fullName && contact?.email
? contact.email
: fullName
? undefined
: contact?.email || contact?.phone || undefined
return {
displayName,
secondaryLine,
}
}
function buildConversationDisplayTitle(
conversation: { title?: string },
contact?: {
firstName?: string
lastName?: string
email?: string
phone?: string
} | null
) {
const title = String(conversation.title || "").trim()
if (title && title.toLowerCase() !== "unknown contact") {
return title
}
return buildContactDisplay(contact || undefined).displayName
}
async function buildContactTimeline(ctx, contactId) {
const conversations = await ctx.db
.query("conversations")
@ -470,10 +514,15 @@ export const importContact = mutation({
},
handler: async (ctx, args) => {
const payload = args.payload || {}
const name = sanitizeContactNameParts({
firstName: payload.firstName || payload.first_name,
lastName: payload.lastName || payload.last_name,
fullName: payload.name,
})
const contact = await upsertContactRecord(ctx, {
firstName:
payload.firstName || payload.first_name || payload.name || "Unknown",
lastName: payload.lastName || payload.last_name || "Contact",
firstName: name.firstName,
lastName: name.lastName,
fullName: payload.name,
email: payload.email,
phone: payload.phone,
company: payload.company || payload.companyName,
@ -511,9 +560,15 @@ export const importConversation = mutation({
},
handler: async (ctx, args) => {
const payload = args.payload || {}
const name = sanitizeContactNameParts({
firstName: payload.firstName,
lastName: payload.lastName,
fullName: payload.contactName || payload.fullName || payload.name,
})
const contact = await upsertContactRecord(ctx, {
firstName: payload.firstName || payload.contactName || "Unknown",
lastName: payload.lastName || "Contact",
firstName: name.firstName,
lastName: name.lastName,
fullName: payload.contactName || payload.fullName || payload.name,
email: payload.email,
phone: payload.phone || payload.contactPhone,
source: `${args.provider}:mirror`,
@ -587,9 +642,15 @@ export const importMessage = mutation({
},
handler: async (ctx, args) => {
const payload = args.payload || {}
const name = sanitizeContactNameParts({
firstName: payload.firstName,
lastName: payload.lastName,
fullName: payload.contactName || payload.fullName || payload.name,
})
const contact = await upsertContactRecord(ctx, {
firstName: payload.firstName || payload.contactName || "Unknown",
lastName: payload.lastName || "Contact",
firstName: name.firstName,
lastName: name.lastName,
fullName: payload.contactName || payload.fullName || payload.name,
email: payload.email,
phone: payload.phone,
source: `${args.provider}:mirror`,
@ -1208,6 +1269,64 @@ export const runGhlMirror = action({
},
})
export const repairMirroredContacts = action({
args: {
reason: v.optional(v.string()),
maxPages: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const config = readGhlMirrorConfig()
if (!config) {
return {
ok: false,
repaired: 0,
message: "GHL credentials are not configured.",
}
}
const maxPages = Math.min(250, Math.max(1, args.maxPages || 50))
const limit = Math.min(100, Math.max(1, args.limit || 100))
let cursor: string | undefined
let pages = 0
let repaired = 0
while (pages < maxPages) {
const fetched = await fetchGhlContactsPage(config, {
limit,
cursor,
})
if (!fetched.items.length) {
break
}
for (const item of fetched.items) {
await ctx.runMutation(api.crm.importContact, {
provider: GHL_SYNC_PROVIDER,
entityId: String(item.id || ""),
payload: item,
})
repaired += 1
}
pages += 1
cursor = fetched.nextCursor
if (!cursor) {
break
}
}
return {
ok: true,
repaired,
pages,
cursor: cursor || null,
reason: args.reason || "manual-repair",
}
},
})
export const listAdminContacts = query({
args: {
search: v.optional(v.string()),
@ -1241,6 +1360,7 @@ export const listAdminContacts = query({
const paged = filtered.slice((page - 1) * limit, page * limit)
const items = await Promise.all(
paged.map(async (contact) => {
const display = buildContactDisplay(contact)
const conversations = await ctx.db
.query("conversations")
.withIndex("by_contactId", (q) => q.eq("contactId", contact._id))
@ -1254,6 +1374,8 @@ export const listAdminContacts = query({
id: contact._id,
firstName: contact.firstName,
lastName: contact.lastName,
displayName: display.displayName,
secondaryLine: display.secondaryLine,
email: contact.email,
phone: contact.phone,
company: contact.company,
@ -1316,6 +1438,8 @@ export const getAdminContactDetail = query({
id: contact._id,
firstName: contact.firstName,
lastName: contact.lastName,
displayName: buildContactDisplay(contact).displayName,
secondaryLine: buildContactDisplay(contact).secondaryLine,
email: contact.email,
phone: contact.phone,
company: contact.company,
@ -1331,7 +1455,7 @@ export const getAdminContactDetail = query({
id: conversation._id,
channel: conversation.channel,
status: conversation.status || "open",
title: conversation.title,
title: buildConversationDisplayTitle(conversation, contact),
lastMessageAt: conversation.lastMessageAt,
lastMessagePreview: conversation.lastMessagePreview,
recordingReady: Boolean(conversation.livekitRoomName || conversation.voiceSessionId),
@ -1408,6 +1532,7 @@ export const listAdminConversations = query({
const paged = filtered.slice((page - 1) * limit, page * limit)
const items = await Promise.all(
paged.map(async ({ conversation, contact }) => {
const display = buildContactDisplay(contact || undefined)
const recordings = await ctx.db
.query("callArtifacts")
.withIndex("by_conversationId", (q) =>
@ -1423,11 +1548,7 @@ export const listAdminConversations = query({
return {
id: conversation._id,
title:
conversation.title ||
(contact
? `${contact.firstName} ${contact.lastName}`.trim()
: "Unnamed conversation"),
title: buildConversationDisplayTitle(conversation, contact),
channel: conversation.channel,
status: conversation.status || "open",
direction: conversation.direction || "mixed",
@ -1435,12 +1556,15 @@ export const listAdminConversations = query({
startedAt: conversation.startedAt,
lastMessageAt: conversation.lastMessageAt,
lastMessagePreview: conversation.lastMessagePreview,
displayName: display.displayName,
secondaryLine: display.secondaryLine,
contact: contact
? {
id: contact._id,
name: `${contact.firstName} ${contact.lastName}`.trim(),
name: display.displayName,
email: contact.email,
phone: contact.phone,
secondaryLine: display.secondaryLine,
}
: null,
messageCount: messages.length,
@ -1503,7 +1627,7 @@ export const getAdminConversationDetail = query({
return {
conversation: {
id: conversation._id,
title: conversation.title,
title: buildConversationDisplayTitle(conversation, contact),
channel: conversation.channel,
status: conversation.status || "open",
direction: conversation.direction || "mixed",
@ -1519,10 +1643,11 @@ export const getAdminConversationDetail = query({
contact: contact
? {
id: contact._id,
name: `${contact.firstName} ${contact.lastName}`.trim(),
name: buildContactDisplay(contact).displayName,
email: contact.email,
phone: contact.phone,
company: contact.company,
secondaryLine: buildContactDisplay(contact).secondaryLine,
}
: null,
participants: participants.map((participant) => ({

View file

@ -24,6 +24,74 @@ export function normalizePhone(value?: string) {
return `+${digits}`
}
function trimOptional(value?: string) {
const trimmed = String(value || "").trim()
return trimmed || undefined
}
function isPlaceholderFirstName(value?: string) {
const normalized = String(value || "")
.trim()
.toLowerCase()
return normalized === "unknown" || normalized === "phone"
}
function isPlaceholderLastName(value?: string) {
const normalized = String(value || "")
.trim()
.toLowerCase()
return (
normalized === "contact" ||
normalized === "lead" ||
normalized === "caller"
)
}
function looksLikePhoneLabel(value?: string) {
const normalized = trimOptional(value)
if (!normalized) {
return false
}
const digits = normalized.replace(/\D/g, "")
return digits.length >= 7 && digits.length <= 15
}
export function sanitizeContactNameParts(args: {
firstName?: string
lastName?: string
fullName?: string
}) {
let firstName = trimOptional(args.firstName)
let lastName = trimOptional(args.lastName)
if (!firstName && !lastName) {
const fullName = trimOptional(args.fullName)
if (fullName && !looksLikePhoneLabel(fullName)) {
const parts = fullName.split(/\s+/).filter(Boolean)
if (parts.length === 1) {
firstName = parts[0]
} else if (parts.length > 1) {
firstName = parts.shift()
lastName = parts.join(" ")
}
}
}
if (isPlaceholderFirstName(firstName)) {
firstName = undefined
}
if (isPlaceholderLastName(lastName)) {
lastName = undefined
}
return {
firstName,
lastName,
}
}
export function dedupeStrings(values?: string[]) {
return Array.from(
new Set(
@ -84,9 +152,19 @@ export async function upsertContactRecord(ctx, input) {
phone: normalizedPhone,
})
const existingName = sanitizeContactNameParts({
firstName: existing?.firstName,
lastName: existing?.lastName,
})
const incomingName = sanitizeContactNameParts({
firstName: input.firstName,
lastName: input.lastName,
fullName: input.fullName,
})
const patch = {
firstName: String(input.firstName || existing?.firstName || "Unknown"),
lastName: String(input.lastName || existing?.lastName || "Contact"),
firstName: incomingName.firstName ?? existingName.firstName ?? "",
lastName: incomingName.lastName ?? existingName.lastName ?? "",
email: input.email || existing?.email,
normalizedEmail: normalizedEmail || existing?.normalizedEmail,
phone: input.phone || existing?.phone,