From e326cc6bba9cdbab5e7500d1d94a774dd98b87bb Mon Sep 17 00:00:00 2001
From: DMleadgen
Date: Thu, 16 Apr 2026 11:02:22 -0600
Subject: [PATCH] feat: ship CRM admin and staging sign-in
---
app/admin/contacts/[id]/page.tsx | 201 ++++
app/admin/contacts/page.tsx | 164 +++
app/admin/conversations/[id]/page.tsx | 241 +++++
app/admin/conversations/page.tsx | 208 ++++
app/admin/layout.tsx | 32 +-
app/admin/page.tsx | 14 +
app/api/admin/auth/login/route.ts | 37 +
app/api/admin/auth/logout/route.ts | 23 +
app/api/admin/contacts/[id]/route.ts | 36 +
app/api/admin/contacts/route.ts | 32 +
app/api/admin/conversations/[id]/route.ts | 39 +
app/api/admin/conversations/route.ts | 45 +
app/api/internal/ghl/shared.ts | 51 +
app/api/internal/ghl/sync/contacts/route.ts | 60 ++
.../internal/ghl/sync/conversations/route.ts | 70 ++
app/api/internal/ghl/sync/messages/route.ts | 61 ++
app/api/internal/ghl/sync/reconcile/route.ts | 29 +
app/api/internal/ghl/sync/recordings/route.ts | 69 ++
app/sign-in/[[...sign-in]]/page.tsx | 85 +-
convex/crm.ts | 982 ++++++++++++++++++
convex/crmModel.ts | 390 +++++++
convex/leads.ts | 217 ++--
convex/schema.ts | 239 ++++-
convex/voiceSessions.ts | 334 +++---
lib/server/admin-auth.ts | 105 ++
lib/server/ghl-sync.ts | 130 +++
26 files changed, 3563 insertions(+), 331 deletions(-)
create mode 100644 app/admin/contacts/[id]/page.tsx
create mode 100644 app/admin/contacts/page.tsx
create mode 100644 app/admin/conversations/[id]/page.tsx
create mode 100644 app/admin/conversations/page.tsx
create mode 100644 app/api/admin/auth/login/route.ts
create mode 100644 app/api/admin/auth/logout/route.ts
create mode 100644 app/api/admin/contacts/[id]/route.ts
create mode 100644 app/api/admin/contacts/route.ts
create mode 100644 app/api/admin/conversations/[id]/route.ts
create mode 100644 app/api/admin/conversations/route.ts
create mode 100644 app/api/internal/ghl/shared.ts
create mode 100644 app/api/internal/ghl/sync/contacts/route.ts
create mode 100644 app/api/internal/ghl/sync/conversations/route.ts
create mode 100644 app/api/internal/ghl/sync/messages/route.ts
create mode 100644 app/api/internal/ghl/sync/reconcile/route.ts
create mode 100644 app/api/internal/ghl/sync/recordings/route.ts
create mode 100644 convex/crm.ts
create mode 100644 convex/crmModel.ts
create mode 100644 lib/server/ghl-sync.ts
diff --git a/app/admin/contacts/[id]/page.tsx b/app/admin/contacts/[id]/page.tsx
new file mode 100644
index 00000000..4a55bd09
--- /dev/null
+++ b/app/admin/contacts/[id]/page.tsx
@@ -0,0 +1,201 @@
+import Link from "next/link"
+import { notFound } from "next/navigation"
+import { fetchQuery } from "convex/nextjs"
+import { ArrowLeft, ContactRound, MessageSquare } from "lucide-react"
+import { api } from "@/convex/_generated/api"
+import { Badge } from "@/components/ui/badge"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+
+type PageProps = {
+ params: Promise<{
+ id: string
+ }>
+}
+
+function formatTimestamp(value?: number) {
+ if (!value) {
+ return "—"
+ }
+
+ return new Date(value).toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+}
+
+export default async function AdminContactDetailPage({ params }: PageProps) {
+ const { id } = await params
+ const detail = await fetchQuery(api.crm.getAdminContactDetail, {
+ contactId: id,
+ })
+
+ if (!detail) {
+ notFound()
+ }
+
+ return (
+
+
+
+
+
+ Back to contacts
+
+
+ {detail.contact.firstName} {detail.contact.lastName}
+
+
+ Unified CRM record across forms, calls, SMS, and sync imports.
+
+
+
+
+
+
+
+
+ Contact Profile
+
+
+ Backend-owned identity and sync metadata.
+
+
+
+
+
+ Email
+
+
+ {detail.contact.email || "—"}
+
+
+
+
+ Phone
+
+
{detail.contact.phone || "—"}
+
+
+
+ Company
+
+
{detail.contact.company || "—"}
+
+
+
+ Status
+
+
+ {detail.contact.status}
+
+
+
+
+ GHL Contact ID
+
+
+ {detail.contact.ghlContactId || "—"}
+
+
+
+
+ Last Activity
+
+
+ {formatTimestamp(detail.contact.lastActivityAt)}
+
+
+
+
+
+
+
+
+
+ Conversations
+
+
+ Every mirrored conversation associated to this contact.
+
+
+
+ {detail.conversations.length === 0 ? (
+
+ No conversations are linked to this contact yet.
+
+ ) : (
+ detail.conversations.map((conversation: any) => (
+
+
+
+
+ {conversation.title || "Untitled conversation"}
+
+
+ {conversation.channel} •{" "}
+ {formatTimestamp(conversation.lastMessageAt)}
+
+
+
+
{conversation.status}
+
+
+
+ {conversation.lastMessagePreview || "No preview yet"}
+
+
+ ))
+ )}
+
+
+
+
+
+
+ Timeline
+
+ Calls, messages, recordings, and lead events in one stream.
+
+
+
+ {detail.timeline.length === 0 ? (
+
+ No timeline activity for this contact yet.
+
+ ) : (
+ detail.timeline.map((item: any) => (
+
+
+ {item.type}
+ {formatTimestamp(item.timestamp)}
+
+
{item.title || "Untitled"}
+
+ {item.body || "—"}
+
+
+ ))
+ )}
+
+
+
+
+ )
+}
+
+export const metadata = {
+ title: "Contact Detail | Admin",
+ description: "Review a Rocky CRM contact and full interaction timeline",
+}
diff --git a/app/admin/contacts/page.tsx b/app/admin/contacts/page.tsx
new file mode 100644
index 00000000..eb60ec58
--- /dev/null
+++ b/app/admin/contacts/page.tsx
@@ -0,0 +1,164 @@
+import Link from "next/link"
+import { fetchQuery } from "convex/nextjs"
+import { ContactRound, Search } from "lucide-react"
+import { api } from "@/convex/_generated/api"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+
+type PageProps = {
+ searchParams: Promise<{
+ search?: string
+ page?: string
+ }>
+}
+
+function formatTimestamp(value?: number) {
+ if (!value) {
+ return "—"
+ }
+
+ return new Date(value).toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+}
+
+export default async function AdminContactsPage({ searchParams }: PageProps) {
+ const params = await searchParams
+ const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
+ const search = params.search?.trim() || undefined
+
+ const data = await fetchQuery(api.crm.listAdminContacts, {
+ search,
+ page,
+ limit: 25,
+ })
+
+ return (
+
+
+
+
+
+ Contacts
+
+
+ Backend-owned CRM contacts mirrored from forms, phone calls, and
+ GHL sync.
+
+
+
+
+
+
+
+
+
+
+
+ Contact Directory
+
+
+ Search by name, email, phone, company, or tag.
+
+
+
+
+
+
+
+
+
+ | Contact |
+ Company |
+ Status |
+ Conversations |
+ Leads |
+ Last Activity |
+ Open |
+
+
+
+ {data.items.length === 0 ? (
+
+ |
+ No contacts matched this filter.
+ |
+
+ ) : (
+ data.items.map((contact: any) => (
+
+ |
+
+ {contact.firstName} {contact.lastName}
+
+
+ {contact.email || "No email"}
+
+
+ {contact.phone || "No phone"}
+
+ |
+
+ {contact.company || "—"}
+ |
+
+ {contact.status}
+ |
+ {contact.conversationCount} |
+ {contact.leadCount} |
+
+ {formatTimestamp(contact.lastActivityAt)}
+ |
+
+
+
+
+ |
+
+ ))
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+export const metadata = {
+ title: "Contacts | Admin",
+ description: "View backend-owned Rocky contact records",
+}
diff --git a/app/admin/conversations/[id]/page.tsx b/app/admin/conversations/[id]/page.tsx
new file mode 100644
index 00000000..200f7d42
--- /dev/null
+++ b/app/admin/conversations/[id]/page.tsx
@@ -0,0 +1,241 @@
+import Link from "next/link"
+import { notFound } from "next/navigation"
+import { fetchQuery } from "convex/nextjs"
+import { ArrowLeft, ExternalLink, MessageSquare } from "lucide-react"
+import { api } from "@/convex/_generated/api"
+import { Badge } from "@/components/ui/badge"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+
+type PageProps = {
+ params: Promise<{
+ id: string
+ }>
+}
+
+function formatTimestamp(value?: number) {
+ if (!value) {
+ return "—"
+ }
+
+ return new Date(value).toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+}
+
+function formatDuration(value?: number) {
+ if (!value) {
+ return "—"
+ }
+ const totalSeconds = Math.round(value / 1000)
+ const minutes = Math.floor(totalSeconds / 60)
+ const seconds = totalSeconds % 60
+ return `${minutes}:${String(seconds).padStart(2, "0")}`
+}
+
+export default async function AdminConversationDetailPage({
+ params,
+}: PageProps) {
+ const { id } = await params
+ const detail = await fetchQuery(api.crm.getAdminConversationDetail, {
+ conversationId: id,
+ })
+
+ if (!detail) {
+ notFound()
+ }
+
+ return (
+
+
+
+
+
+ Back to conversations
+
+
+ {detail.conversation.title || "Conversation Detail"}
+
+
+ Unified thread for Rocky-owned conversation management.
+
+
+
+
+
+
+
+
+ Conversation Status
+
+
+ Channel, ownership, and sync metadata.
+
+
+
+
+
+ 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
+
+ Full backend-owned thread 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}
+
+ ))
+ )}
+
+
+
+
+ )
+}
+
+export const metadata = {
+ title: "Conversation Detail | Admin",
+ description: "Review a Rocky conversation thread, recordings, and leads",
+}
diff --git a/app/admin/conversations/page.tsx b/app/admin/conversations/page.tsx
new file mode 100644
index 00000000..560ff8ad
--- /dev/null
+++ b/app/admin/conversations/page.tsx
@@ -0,0 +1,208 @@
+import Link from "next/link"
+import { fetchQuery } from "convex/nextjs"
+import { MessageSquare, Search } from "lucide-react"
+import { api } from "@/convex/_generated/api"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+
+type PageProps = {
+ searchParams: Promise<{
+ search?: string
+ channel?: "call" | "sms" | "chat" | "unknown"
+ status?: "open" | "closed" | "archived"
+ page?: string
+ }>
+}
+
+function formatTimestamp(value?: number) {
+ if (!value) {
+ return "—"
+ }
+
+ return new Date(value).toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+}
+
+export default async function AdminConversationsPage({
+ searchParams,
+}: PageProps) {
+ const params = await searchParams
+ const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
+ const search = params.search?.trim() || undefined
+
+ const data = await fetchQuery(api.crm.listAdminConversations, {
+ search,
+ page,
+ limit: 25,
+ channel: params.channel,
+ status: params.status,
+ })
+
+ return (
+
+
+
+
+
+ Conversations
+
+
+ Unified inbox across backend-owned call and SMS conversation
+ threads.
+
+
+
+
+
+
+
+
+
+
+
+ Conversation Inbox
+
+
+ Search by contact, conversation preview, phone, email, or external
+ ID.
+
+
+
+
+
+
+
+
+
+ | Conversation |
+ Contact |
+ Channel |
+ Status |
+ Messages |
+ Recordings |
+ Last Activity |
+ Open |
+
+
+
+ {data.items.length === 0 ? (
+
+ |
+ No conversations matched this filter.
+ |
+
+ ) : (
+ data.items.map((conversation: any) => (
+
+ |
+
+ {conversation.title || "Untitled conversation"}
+
+
+ {conversation.lastMessagePreview || "No preview yet"}
+
+ |
+
+ {conversation.contact ? (
+
+
+ {conversation.contact.name}
+
+
+ {conversation.contact.phone ||
+ conversation.contact.email ||
+ "—"}
+
+
+ ) : (
+ "—"
+ )}
+ |
+
+ {conversation.channel}
+ |
+
+ {conversation.status}
+ |
+ {conversation.messageCount} |
+
+ {conversation.recordingCount}
+ |
+
+ {formatTimestamp(conversation.lastMessageAt)}
+ |
+
+
+
+
+ |
+
+ ))
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+export const metadata = {
+ title: "Conversations | Admin",
+ description: "View backend-owned Rocky conversation threads",
+}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index a7674a3a..38a23f98 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -1,5 +1,9 @@
+import Link from "next/link"
import { redirect } from "next/navigation"
-import { isAdminUiEnabled } from "@/lib/server/admin-auth"
+import {
+ getAdminUserFromCookies,
+ isAdminUiEnabled,
+} from "@/lib/server/admin-auth"
export default async function AdminLayout({
children,
@@ -10,5 +14,29 @@ export default async function AdminLayout({
redirect("/")
}
- return <>{children}>
+ const adminUser = await getAdminUserFromCookies()
+ if (!adminUser) {
+ redirect("/sign-in")
+ }
+
+ return (
+
+
+
+
+
+ Rocky Admin
+
+ {adminUser.email}
+
+
+
+
+ {children}
+
+ )
}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index 058e5ed7..071adf45 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -21,6 +21,8 @@ import {
Settings,
BarChart3,
Phone,
+ MessageSquare,
+ ContactRound,
} from "lucide-react"
import { fetchAllProducts } from "@/lib/stripe/products"
@@ -196,6 +198,18 @@ export default async function AdminDashboard() {
+
+
+
+
+
+