feat: rebuild CRM inbox and contact mapping
This commit is contained in:
parent
14cb8ce1fc
commit
e294117e6e
6 changed files with 599 additions and 395 deletions
|
|
@ -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} •{" "}
|
||||
|
|
|
|||
|
|
@ -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 || "—"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
157
convex/crm.ts
157
convex/crm.ts
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue