From e294117e6e9b73968f4de596553ff4733ee672c4 Mon Sep 17 00:00:00 2001 From: DMleadgen Date: Thu, 16 Apr 2026 13:29:53 -0600 Subject: [PATCH] feat: rebuild CRM inbox and contact mapping --- app/admin/contacts/[id]/page.tsx | 4 +- app/admin/contacts/page.tsx | 20 +- app/admin/conversations/[id]/page.tsx | 226 +----------- app/admin/conversations/page.tsx | 505 ++++++++++++++++++-------- convex/crm.ts | 157 +++++++- convex/crmModel.ts | 82 ++++- 6 files changed, 599 insertions(+), 395 deletions(-) diff --git a/app/admin/contacts/[id]/page.tsx b/app/admin/contacts/[id]/page.tsx index 274b395b..0d988fa7 100644 --- a/app/admin/contacts/[id]/page.tsx +++ b/app/admin/contacts/[id]/page.tsx @@ -54,7 +54,7 @@ export default async function AdminContactDetailPage({ params }: PageProps) { Back to contacts

- {detail.contact.firstName} {detail.contact.lastName} + {detail.contact.displayName}

Contact details and activity history. @@ -139,7 +139,7 @@ export default async function AdminContactDetailPage({ params }: PageProps) {

- {conversation.title || "Untitled conversation"} + {conversation.title || detail.contact.displayName}

{conversation.channel} •{" "} diff --git a/app/admin/contacts/page.tsx b/app/admin/contacts/page.tsx index 77910130..f19cd489 100644 --- a/app/admin/contacts/page.tsx +++ b/app/admin/contacts/page.tsx @@ -153,15 +153,17 @@ export default async function AdminContactsPage({ searchParams }: PageProps) { className="border-b align-top last:border-b-0" > -

- {contact.firstName} {contact.lastName} -
-
- {contact.email || "No email"} -
-
- {contact.phone || "No phone"} -
+
{contact.displayName}
+ {contact.email ? ( +
+ {contact.email} +
+ ) : null} + {contact.phone ? ( +
+ {contact.phone} +
+ ) : null} {contact.company || "—"} diff --git a/app/admin/conversations/[id]/page.tsx b/app/admin/conversations/[id]/page.tsx index 0fb56d3e..24028d55 100644 --- a/app/admin/conversations/[id]/page.tsx +++ b/app/admin/conversations/[id]/page.tsx @@ -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 ( -
-
-
- - - Back to conversations - -

- {detail.conversation.title || "Conversation Detail"} -

-

- Full conversation history in one place. -

-
- -
- - - - - Conversation Status - - Channel, contact, and latest activity. - - -
-

- Channel -

- - {detail.conversation.channel} - -
-
-

- Status -

- - {detail.conversation.status} - -
-
-

- Contact -

-

- {detail.contact?.name || "Unlinked"} -

-
-
-

- Started -

-

- {formatTimestamp(detail.conversation.startedAt)} -

-
-
-

- Last Activity -

-

- {formatTimestamp(detail.conversation.lastMessageAt)} -

-
-
-

- GHL Conversation ID -

-

- {detail.conversation.ghlConversationId || "—"} -

-
- {detail.conversation.summaryText ? ( -
-

- Summary -

-

- {detail.conversation.summaryText} -

-
- ) : null} -
-
- - - - Recordings & Leads - - Call artifacts and related lead outcomes for this thread. - - - - {detail.recordings.map((recording: any) => ( -
-
- - {recording.recordingStatus || "recording"} - - - {formatDuration(recording.durationMs)} - -
- {recording.recordingUrl ? ( - - Open recording - - - ) : null} - {recording.transcriptionText ? ( -

- {recording.transcriptionText} -

- ) : null} -
- ))} - - {detail.leads.map((lead: any) => ( -
-
-

{lead.type}

- {lead.status} -
-

- {lead.message || lead.intent || "—"} -

-
- ))} - - {detail.recordings.length === 0 && detail.leads.length === 0 ? ( -

- No recordings or linked leads for this conversation yet. -

- ) : null} -
-
-
- - - - Messages - Message history for this conversation. - - - {detail.messages.length === 0 ? ( -

- No messages have been mirrored into this conversation yet. -

- ) : ( - detail.messages.map((message: any) => ( -
-
- - {message.channel} • {message.direction} - - {formatTimestamp(message.sentAt)} -
-

{message.body}

-
- )) - )} -
-
-
-
- ) + 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", } diff --git a/app/admin/conversations/page.tsx b/app/admin/conversations/page.tsx index eac2fdbd..1bfafa54 100644 --- a/app/admin/conversations/page.tsx +++ b/app/admin/conversations/page.tsx @@ -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 (
-
+

Conversations

- Customer conversations in one inbox. + Review calls and messages in one inbox.

@@ -84,153 +181,273 @@ export default async function AdminConversationsPage({
- - - Sync Status - {getSyncMessage(data.sync)} - - + + {data.sync.overallStatus} - - Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)} - - {!data.sync.ghlConfigured ? ( - GHL is not connected. - ) : null} - {data.sync.stages.conversations.error ? ( - {data.sync.stages.conversations.error} - ) : null} + {getSyncMessage(data.sync)} + Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)} - - - - - Conversation Inbox - - - Search by contact, phone, email, or recent message. - - - -
-
- - -
- - - -
+ +
+
+
+
+ +
+

Conversation Inbox

+

+ Search and pick a conversation to review. +

+
+
-
- - - - - - - - - - - - - - + +
+ + +
+
+ + + +
+ + + + +
{data.items.length === 0 ? ( -
- - +
+ {search || params.channel || params.status + ? "No conversations matched this search." + : getSyncMessage(data.sync)} +
) : ( - data.items.map((conversation: any) => ( - - - - - - - - - - - )) - )} - -
ConversationContactChannelStatusMessagesRecordingsLast ActivityOpen
- {search || params.channel || params.status - ? "No conversations matched this search." - : getSyncMessage(data.sync)} -
-
- {conversation.title || "Untitled conversation"} + data.items.map((conversation: any) => { + const isSelected = conversation.id === selectedConversationId + return ( + +
+ {getInitials(conversation.displayName)}
-
- {conversation.lastMessagePreview || "No preview yet"} -
-
- {conversation.contact ? ( -
-
- {conversation.contact.name} +
+
+
+

+ {conversation.displayName} +

+ {conversation.secondaryLine ? ( +

+ {conversation.secondaryLine} +

+ ) : null}
-
- {conversation.contact.phone || - conversation.contact.email || - "—"} + + {formatSidebarTimestamp(conversation.lastMessageAt)} + +
+

+ {conversation.lastMessagePreview || + "No messages or call notes yet."} +

+
+ {conversation.channel} + {conversation.status} + {conversation.recordingCount ? ( + + {conversation.recordingCount} recording + {conversation.recordingCount === 1 ? "" : "s"} + + ) : null} +
+
+ + ) + }) + )} +
+ +
+ +
+ {detail ? ( +
+
+
+
+
+

+ {detail.contact?.name || + detail.conversation.title || + "Conversation"} +

+ {detail.contact?.secondaryLine || + detail.contact?.email || + detail.contact?.phone ? ( +

+ {detail.contact?.secondaryLine || + detail.contact?.phone || + detail.contact?.email} +

+ ) : null} +
+
+ + {detail.conversation.channel} + + + {detail.conversation.status} + + + {timeline.filter((item) => item.type === "message").length}{" "} + messages + + {detail.recordings.length ? ( + + {detail.recordings.length} recording + {detail.recordings.length === 1 ? "" : "s"} + + ) : null} +
+
+
+ Last activity:{" "} + {formatTimestamp(detail.conversation.lastMessageAt)} +
+
+
+ + +
+ {timeline.length === 0 ? ( +
+ No messages or recordings have been mirrored into this + conversation yet. +
+ ) : ( + timeline.map((item: any) => { + if (item.type === "recording") { + const recording = item.recording + return ( +
+
+ + Call recording + + {recording.recordingStatus || "recording"} + +
+
+ {formatTimestamp(recording.startedAt)} + Duration: {formatDuration(recording.durationMs)} +
+ {recording.recordingUrl ? ( + + ) : null} + {recording.transcriptionText ? ( +
+ {recording.transcriptionText} +
+ ) : null} +
+ ) + } + + const message = item.message + const isOutbound = message.direction === "outbound" + + return ( +
+
+
+ {message.channel} + {message.direction} + {message.status ? {message.status} : null} +
+

+ {message.body} +

+
+ {formatTimestamp(message.sentAt)} +
- ) : ( - "—" - )} -
- {conversation.channel} - - {conversation.status} - {conversation.messageCount} - {conversation.recordingCount} - - {formatTimestamp(conversation.lastMessageAt)} - - - - -
+ ) + }) + )} +
+ +
+ ) : ( +
+
+

No conversation selected

+

+ Choose a conversation from the left to open the full thread. +

+
+
+ )}
- +
diff --git a/convex/crm.ts b/convex/crm.ts index 401f8183..7591213b 100644 --- a/convex/crm.ts +++ b/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) => ({ diff --git a/convex/crmModel.ts b/convex/crmModel.ts index 2ec06c80..7192745a 100644 --- a/convex/crmModel.ts +++ b/convex/crmModel.ts @@ -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,