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 Back to contacts
</Link> </Link>
<h1 className="text-4xl font-bold tracking-tight text-balance"> <h1 className="text-4xl font-bold tracking-tight text-balance">
{detail.contact.firstName} {detail.contact.lastName} {detail.contact.displayName}
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Contact details and activity history. 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 className="flex items-center justify-between gap-3">
<div> <div>
<p className="font-medium"> <p className="font-medium">
{conversation.title || "Untitled conversation"} {conversation.title || detail.contact.displayName}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{conversation.channel} {" "} {conversation.channel} {" "}

View file

@ -153,15 +153,17 @@ export default async function AdminContactsPage({ searchParams }: PageProps) {
className="border-b align-top last:border-b-0" className="border-b align-top last:border-b-0"
> >
<td className="py-3 pr-4"> <td className="py-3 pr-4">
<div className="font-medium"> <div className="font-medium">{contact.displayName}</div>
{contact.firstName} {contact.lastName} {contact.email ? (
</div> <div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground"> {contact.email}
{contact.email || "No email"} </div>
</div> ) : null}
<div className="text-xs text-muted-foreground"> {contact.phone ? (
{contact.phone || "No phone"} <div className="text-xs text-muted-foreground">
</div> {contact.phone}
</div>
) : null}
</td> </td>
<td className="py-3 pr-4"> <td className="py-3 pr-4">
{contact.company || "—"} {contact.company || "—"}

View file

@ -1,16 +1,4 @@
import Link from "next/link" import { redirect } from "next/navigation"
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 = { type PageProps = {
params: Promise<{ params: Promise<{
@ -18,220 +6,14 @@ type PageProps = {
}> }>
} }
function formatTimestamp(value?: number) { export default async function AdminConversationDetailRedirect({
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, params,
}: PageProps) { }: PageProps) {
const { id } = await params const { id } = await params
const detail = await fetchQuery(api.crm.getAdminConversationDetail, { redirect(`/admin/conversations?conversationId=${encodeURIComponent(id)}`)
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>
)
} }
export const metadata = { export const metadata = {
title: "Conversation Detail | Admin", 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 Link from "next/link"
import { fetchQuery } from "convex/nextjs" 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 { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -12,12 +12,14 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
type PageProps = { type PageProps = {
searchParams: Promise<{ searchParams: Promise<{
search?: string search?: string
channel?: "call" | "sms" | "chat" | "unknown" channel?: "call" | "sms" | "chat" | "unknown"
status?: "open" | "closed" | "archived" status?: "open" | "closed" | "archived"
conversationId?: string
page?: string page?: string
}> }>
} }
@ -30,12 +32,42 @@ function formatTimestamp(value?: number) {
return new Date(value).toLocaleString("en-US", { return new Date(value).toLocaleString("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", hour: "numeric",
hour: "2-digit",
minute: "2-digit", 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) { function getSyncMessage(sync: any) {
if (!sync.ghlConfigured) { if (!sync.ghlConfigured) {
return "Connect GHL to load contacts and conversations." return "Connect GHL to load contacts and conversations."
@ -49,34 +81,99 @@ function getSyncMessage(sync: any) {
if (!sync.latestSyncAt) { if (!sync.latestSyncAt) {
return "No conversations yet." 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({ export default async function AdminConversationsPage({
searchParams, searchParams,
}: PageProps) { }: PageProps) {
const params = await searchParams const params = await searchParams
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
const search = params.search?.trim() || undefined const search = params.search?.trim() || undefined
const data = await fetchQuery(api.crm.listAdminConversations, { const data = await fetchQuery(api.crm.listAdminConversations, {
search, search,
page, page: 1,
limit: 25, limit: 100,
channel: params.channel, channel: params.channel,
status: params.status, 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 ( return (
<div className="container mx-auto px-4 py-8"> <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 className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div>
<h1 className="text-4xl font-bold tracking-tight text-balance"> <h1 className="text-4xl font-bold tracking-tight text-balance">
Conversations Conversations
</h1> </h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Customer conversations in one inbox. Review calls and messages in one inbox.
</p> </p>
</div> </div>
<Link href="/admin"> <Link href="/admin">
@ -84,153 +181,273 @@ export default async function AdminConversationsPage({
</Link> </Link>
</div> </div>
<Card> <Card className="rounded-[2rem]">
<CardHeader> <CardContent className="flex flex-wrap items-center gap-3 px-6 py-4 text-sm text-muted-foreground">
<CardTitle>Sync Status</CardTitle>
<CardDescription>{getSyncMessage(data.sync)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<Badge variant="outline">{data.sync.overallStatus}</Badge> <Badge variant="outline">{data.sync.overallStatus}</Badge>
<span> <span>{getSyncMessage(data.sync)}</span>
Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)} <span>Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}</span>
</span>
{!data.sync.ghlConfigured ? (
<span>GHL is not connected.</span>
) : null}
{data.sync.stages.conversations.error ? (
<span>{data.sync.stages.conversations.error}</span>
) : null}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className="overflow-hidden rounded-[2rem] p-0">
<CardHeader> <div className="grid min-h-[720px] lg:grid-cols-[360px_minmax(0,1fr)]">
<CardTitle className="flex items-center gap-2"> <div className="border-b bg-white lg:border-b-0 lg:border-r">
<MessageSquare className="h-5 w-5" /> <div className="space-y-4 border-b px-5 py-5">
Conversation Inbox <div className="flex items-center gap-2">
</CardTitle> <MessageSquare className="h-5 w-5 text-muted-foreground" />
<CardDescription> <div>
Search by contact, phone, email, or recent message. <h2 className="font-semibold">Conversation Inbox</h2>
</CardDescription> <p className="text-sm text-muted-foreground">
</CardHeader> Search and pick a conversation to review.
<CardContent className="space-y-4"> </p>
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_170px_170px_auto]"> </div>
<div className="relative"> </div>
<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"> <form className="space-y-3">
<table className="w-full min-w-[1100px] text-sm"> <div className="relative">
<thead> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<tr className="border-b text-left text-muted-foreground"> <Input
<th className="py-3 pr-4 font-medium">Conversation</th> name="search"
<th className="py-3 pr-4 font-medium">Contact</th> defaultValue={search || ""}
<th className="py-3 pr-4 font-medium">Channel</th> placeholder="Search contacts or messages"
<th className="py-3 pr-4 font-medium">Status</th> className="pl-9"
<th className="py-3 pr-4 font-medium">Messages</th> />
<th className="py-3 pr-4 font-medium">Recordings</th> </div>
<th className="py-3 pr-4 font-medium">Last Activity</th> <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<th className="py-3 font-medium">Open</th> <select
</tr> name="channel"
</thead> defaultValue={params.channel || ""}
<tbody> 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 ? ( {data.items.length === 0 ? (
<tr> <div className="px-5 py-8 text-sm text-muted-foreground">
<td {search || params.channel || params.status
colSpan={8} ? "No conversations matched this search."
className="py-8 text-center text-muted-foreground" : getSyncMessage(data.sync)}
> </div>
{search || params.channel || params.status
? "No conversations matched this search."
: getSyncMessage(data.sync)}
</td>
</tr>
) : ( ) : (
data.items.map((conversation: any) => ( data.items.map((conversation: any) => {
<tr const isSelected = conversation.id === selectedConversationId
key={conversation.id} return (
className="border-b align-top last:border-b-0" <Link
> key={conversation.id}
<td className="py-3 pr-4"> href={buildConversationHref({
<div className="font-medium"> search,
{conversation.title || "Untitled conversation"} 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>
<div className="text-xs text-muted-foreground"> <div className="min-w-0 flex-1">
{conversation.lastMessagePreview || "No preview yet"} <div className="flex items-start justify-between gap-3">
</div> <div className="min-w-0">
</td> <p className="truncate font-medium">
<td className="py-3 pr-4"> {conversation.displayName}
{conversation.contact ? ( </p>
<div> {conversation.secondaryLine ? (
<div className="font-medium"> <p className="truncate text-xs text-muted-foreground">
{conversation.contact.name} {conversation.secondaryLine}
</p>
) : null}
</div> </div>
<div className="text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{conversation.contact.phone || {formatSidebarTimestamp(conversation.lastMessageAt)}
conversation.contact.email || </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>
</div> </div>
) : ( )
"—" })
)} )}
</td> </div>
<td className="py-3 pr-4"> </ScrollArea>
<Badge variant="outline">{conversation.channel}</Badge> </div>
</td> ) : (
<td className="py-3 pr-4"> <div className="flex h-full min-h-[520px] items-center justify-center px-6 py-16">
<Badge variant="secondary">{conversation.status}</Badge> <div className="max-w-md text-center">
</td> <h2 className="text-2xl font-semibold">No conversation selected</h2>
<td className="py-3 pr-4">{conversation.messageCount}</td> <p className="mt-2 text-sm text-muted-foreground">
<td className="py-3 pr-4"> Choose a conversation from the left to open the full thread.
{conversation.recordingCount} </p>
</td> </div>
<td className="py-3 pr-4"> </div>
{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> </div>
</CardContent> </div>
</Card> </Card>
</div> </div>
</div> </div>

View file

@ -6,6 +6,7 @@ import {
ensureConversationParticipant, ensureConversationParticipant,
normalizeEmail, normalizeEmail,
normalizePhone, normalizePhone,
sanitizeContactNameParts,
upsertCallArtifactRecord, upsertCallArtifactRecord,
upsertContactRecord, upsertContactRecord,
upsertConversationRecord, upsertConversationRecord,
@ -229,6 +230,49 @@ function normalizeRecordingStatus(value?: string) {
return "pending" 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) { async function buildContactTimeline(ctx, contactId) {
const conversations = await ctx.db const conversations = await ctx.db
.query("conversations") .query("conversations")
@ -470,10 +514,15 @@ export const importContact = mutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const payload = args.payload || {} 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, { const contact = await upsertContactRecord(ctx, {
firstName: firstName: name.firstName,
payload.firstName || payload.first_name || payload.name || "Unknown", lastName: name.lastName,
lastName: payload.lastName || payload.last_name || "Contact", fullName: payload.name,
email: payload.email, email: payload.email,
phone: payload.phone, phone: payload.phone,
company: payload.company || payload.companyName, company: payload.company || payload.companyName,
@ -511,9 +560,15 @@ export const importConversation = mutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const payload = args.payload || {} 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, { const contact = await upsertContactRecord(ctx, {
firstName: payload.firstName || payload.contactName || "Unknown", firstName: name.firstName,
lastName: payload.lastName || "Contact", lastName: name.lastName,
fullName: payload.contactName || payload.fullName || payload.name,
email: payload.email, email: payload.email,
phone: payload.phone || payload.contactPhone, phone: payload.phone || payload.contactPhone,
source: `${args.provider}:mirror`, source: `${args.provider}:mirror`,
@ -587,9 +642,15 @@ export const importMessage = mutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const payload = args.payload || {} 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, { const contact = await upsertContactRecord(ctx, {
firstName: payload.firstName || payload.contactName || "Unknown", firstName: name.firstName,
lastName: payload.lastName || "Contact", lastName: name.lastName,
fullName: payload.contactName || payload.fullName || payload.name,
email: payload.email, email: payload.email,
phone: payload.phone, phone: payload.phone,
source: `${args.provider}:mirror`, 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({ export const listAdminContacts = query({
args: { args: {
search: v.optional(v.string()), search: v.optional(v.string()),
@ -1241,6 +1360,7 @@ export const listAdminContacts = query({
const paged = filtered.slice((page - 1) * limit, page * limit) const paged = filtered.slice((page - 1) * limit, page * limit)
const items = await Promise.all( const items = await Promise.all(
paged.map(async (contact) => { paged.map(async (contact) => {
const display = buildContactDisplay(contact)
const conversations = await ctx.db const conversations = await ctx.db
.query("conversations") .query("conversations")
.withIndex("by_contactId", (q) => q.eq("contactId", contact._id)) .withIndex("by_contactId", (q) => q.eq("contactId", contact._id))
@ -1254,6 +1374,8 @@ export const listAdminContacts = query({
id: contact._id, id: contact._id,
firstName: contact.firstName, firstName: contact.firstName,
lastName: contact.lastName, lastName: contact.lastName,
displayName: display.displayName,
secondaryLine: display.secondaryLine,
email: contact.email, email: contact.email,
phone: contact.phone, phone: contact.phone,
company: contact.company, company: contact.company,
@ -1316,6 +1438,8 @@ export const getAdminContactDetail = query({
id: contact._id, id: contact._id,
firstName: contact.firstName, firstName: contact.firstName,
lastName: contact.lastName, lastName: contact.lastName,
displayName: buildContactDisplay(contact).displayName,
secondaryLine: buildContactDisplay(contact).secondaryLine,
email: contact.email, email: contact.email,
phone: contact.phone, phone: contact.phone,
company: contact.company, company: contact.company,
@ -1331,7 +1455,7 @@ export const getAdminContactDetail = query({
id: conversation._id, id: conversation._id,
channel: conversation.channel, channel: conversation.channel,
status: conversation.status || "open", status: conversation.status || "open",
title: conversation.title, title: buildConversationDisplayTitle(conversation, contact),
lastMessageAt: conversation.lastMessageAt, lastMessageAt: conversation.lastMessageAt,
lastMessagePreview: conversation.lastMessagePreview, lastMessagePreview: conversation.lastMessagePreview,
recordingReady: Boolean(conversation.livekitRoomName || conversation.voiceSessionId), recordingReady: Boolean(conversation.livekitRoomName || conversation.voiceSessionId),
@ -1408,6 +1532,7 @@ export const listAdminConversations = query({
const paged = filtered.slice((page - 1) * limit, page * limit) const paged = filtered.slice((page - 1) * limit, page * limit)
const items = await Promise.all( const items = await Promise.all(
paged.map(async ({ conversation, contact }) => { paged.map(async ({ conversation, contact }) => {
const display = buildContactDisplay(contact || undefined)
const recordings = await ctx.db const recordings = await ctx.db
.query("callArtifacts") .query("callArtifacts")
.withIndex("by_conversationId", (q) => .withIndex("by_conversationId", (q) =>
@ -1423,11 +1548,7 @@ export const listAdminConversations = query({
return { return {
id: conversation._id, id: conversation._id,
title: title: buildConversationDisplayTitle(conversation, contact),
conversation.title ||
(contact
? `${contact.firstName} ${contact.lastName}`.trim()
: "Unnamed conversation"),
channel: conversation.channel, channel: conversation.channel,
status: conversation.status || "open", status: conversation.status || "open",
direction: conversation.direction || "mixed", direction: conversation.direction || "mixed",
@ -1435,12 +1556,15 @@ export const listAdminConversations = query({
startedAt: conversation.startedAt, startedAt: conversation.startedAt,
lastMessageAt: conversation.lastMessageAt, lastMessageAt: conversation.lastMessageAt,
lastMessagePreview: conversation.lastMessagePreview, lastMessagePreview: conversation.lastMessagePreview,
displayName: display.displayName,
secondaryLine: display.secondaryLine,
contact: contact contact: contact
? { ? {
id: contact._id, id: contact._id,
name: `${contact.firstName} ${contact.lastName}`.trim(), name: display.displayName,
email: contact.email, email: contact.email,
phone: contact.phone, phone: contact.phone,
secondaryLine: display.secondaryLine,
} }
: null, : null,
messageCount: messages.length, messageCount: messages.length,
@ -1503,7 +1627,7 @@ export const getAdminConversationDetail = query({
return { return {
conversation: { conversation: {
id: conversation._id, id: conversation._id,
title: conversation.title, title: buildConversationDisplayTitle(conversation, contact),
channel: conversation.channel, channel: conversation.channel,
status: conversation.status || "open", status: conversation.status || "open",
direction: conversation.direction || "mixed", direction: conversation.direction || "mixed",
@ -1519,10 +1643,11 @@ export const getAdminConversationDetail = query({
contact: contact contact: contact
? { ? {
id: contact._id, id: contact._id,
name: `${contact.firstName} ${contact.lastName}`.trim(), name: buildContactDisplay(contact).displayName,
email: contact.email, email: contact.email,
phone: contact.phone, phone: contact.phone,
company: contact.company, company: contact.company,
secondaryLine: buildContactDisplay(contact).secondaryLine,
} }
: null, : null,
participants: participants.map((participant) => ({ participants: participants.map((participant) => ({

View file

@ -24,6 +24,74 @@ export function normalizePhone(value?: string) {
return `+${digits}` 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[]) { export function dedupeStrings(values?: string[]) {
return Array.from( return Array.from(
new Set( new Set(
@ -84,9 +152,19 @@ export async function upsertContactRecord(ctx, input) {
phone: normalizedPhone, 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 = { const patch = {
firstName: String(input.firstName || existing?.firstName || "Unknown"), firstName: incomingName.firstName ?? existingName.firstName ?? "",
lastName: String(input.lastName || existing?.lastName || "Contact"), lastName: incomingName.lastName ?? existingName.lastName ?? "",
email: input.email || existing?.email, email: input.email || existing?.email,
normalizedEmail: normalizedEmail || existing?.normalizedEmail, normalizedEmail: normalizedEmail || existing?.normalizedEmail,
phone: input.phone || existing?.phone, phone: input.phone || existing?.phone,