Rocky_Mountain_Vending/convex/leads.ts

457 lines
13 KiB
TypeScript

// @ts-nocheck
import { action, mutation } from "./_generated/server"
import { v } from "convex/values"
import {
ensureConversationParticipant,
upsertContactRecord,
upsertConversationRecord,
upsertMessageRecord,
} from "./crmModel"
const leadSyncStatus = v.union(
v.literal("pending"),
v.literal("sent"),
v.literal("synced"),
v.literal("failed"),
v.literal("skipped")
)
const baseLead = {
firstName: v.string(),
lastName: v.string(),
email: v.string(),
phone: v.string(),
company: v.optional(v.string()),
intent: v.optional(v.string()),
message: v.optional(v.string()),
source: v.optional(v.string()),
page: v.optional(v.string()),
url: v.optional(v.string()),
serviceTextConsent: v.optional(v.boolean()),
marketingTextConsent: v.optional(v.boolean()),
consentVersion: v.optional(v.string()),
consentCapturedAt: v.optional(v.string()),
consentSourcePage: v.optional(v.string()),
}
function splitName(name: string) {
const trimmed = String(name || "").trim()
if (!trimmed) {
return { firstName: "Unknown", lastName: "Lead" }
}
const parts = trimmed.split(/\s+/)
const firstName = parts.shift() || "Unknown"
const lastName = parts.join(" ") || "Lead"
return { firstName, lastName }
}
function mapServiceToType(service: string | undefined) {
return String(service || "").toLowerCase() === "machine-request"
? "requestMachine"
: "contact"
}
function deriveSubmissionStatus(usesendStatus?: string, ghlStatus?: string) {
if (usesendStatus === "sent" || ghlStatus === "synced") {
return "delivered"
}
if (usesendStatus === "failed" && ghlStatus === "failed") {
return "failed"
}
return "pending"
}
async function sendWebhook(
webhookUrl: string | undefined,
payload: Record<string, unknown>
) {
if (!webhookUrl) {
return { success: false, error: "Webhook URL not configured" }
}
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
if (!response.ok) {
return {
success: false,
error: `${response.status} ${response.statusText}`,
}
}
return { success: true }
}
export const createLead = mutation({
args: {
type: v.union(v.literal("contact"), v.literal("requestMachine")),
idempotencyKey: v.optional(v.string()),
firstName: v.string(),
lastName: v.string(),
email: v.string(),
phone: v.string(),
company: v.optional(v.string()),
intent: v.optional(v.string()),
message: v.optional(v.string()),
source: v.optional(v.string()),
page: v.optional(v.string()),
url: v.optional(v.string()),
employeeCount: v.optional(v.string()),
machineType: v.optional(v.string()),
machineCount: v.optional(v.string()),
serviceTextConsent: v.optional(v.boolean()),
marketingTextConsent: v.optional(v.boolean()),
consentVersion: v.optional(v.string()),
consentCapturedAt: v.optional(v.string()),
consentSourcePage: v.optional(v.string()),
marketingConsent: v.optional(v.boolean()),
termsAgreement: v.optional(v.boolean()),
usesendStatus: v.optional(leadSyncStatus),
ghlStatus: v.optional(leadSyncStatus),
status: v.union(
v.literal("pending"),
v.literal("delivered"),
v.literal("failed")
),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now()
const contact = await upsertContactRecord(ctx, {
firstName: args.firstName,
lastName: args.lastName,
email: args.email,
phone: args.phone,
company: args.company,
source: args.source,
status: args.status === "delivered" ? "active" : "lead",
lastActivityAt: now,
})
const conversation = await upsertConversationRecord(ctx, {
contactId: contact?._id,
title:
args.type === "requestMachine"
? "Machine request"
: "Website contact",
channel: "chat",
source: args.source || "website",
status: args.status === "failed" ? "archived" : "open",
direction: "inbound",
startedAt: now,
lastMessageAt: now,
lastMessagePreview: args.message || args.intent,
summaryText: args.intent,
})
await ensureConversationParticipant(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
role: "contact",
displayName: `${args.firstName} ${args.lastName}`.trim(),
phone: args.phone,
email: args.email,
})
if (args.message || args.intent) {
await upsertMessageRecord(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
direction: "inbound",
channel: "chat",
source: args.source || "website",
messageType: args.type,
body: args.message || args.intent || "",
status: args.status,
sentAt: now,
})
}
return await ctx.db.insert("leadSubmissions", {
...args,
contactId: contact?._id,
conversationId: conversation?._id,
createdAt: now,
updatedAt: now,
deliveredAt: args.status === "delivered" ? now : undefined,
})
},
})
export const ingestLead = mutation({
args: {
host: v.string(),
tenantSlug: v.optional(v.string()),
tenantName: v.optional(v.string()),
tenantDomains: v.optional(v.array(v.string())),
source: v.optional(v.string()),
idempotencyKey: v.string(),
name: v.string(),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
email: v.string(),
phone: v.string(),
company: v.optional(v.string()),
service: v.optional(v.string()),
intent: v.optional(v.string()),
page: v.optional(v.string()),
url: v.optional(v.string()),
message: v.string(),
employeeCount: v.optional(v.string()),
machineType: v.optional(v.string()),
machineCount: v.optional(v.string()),
serviceTextConsent: v.optional(v.boolean()),
marketingTextConsent: v.optional(v.boolean()),
consentVersion: v.optional(v.string()),
consentCapturedAt: v.optional(v.string()),
consentSourcePage: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("leadSubmissions")
.withIndex("by_idempotencyKey", (q) =>
q.eq("idempotencyKey", args.idempotencyKey)
)
.unique()
if (existing) {
return {
inserted: false,
leadId: existing._id,
idempotencyKey: args.idempotencyKey,
tenantId: args.tenantSlug || args.host,
}
}
const fallbackName = splitName(args.name)
const type = mapServiceToType(args.service)
const now = Date.now()
const contact = await upsertContactRecord(ctx, {
firstName: args.firstName || fallbackName.firstName,
lastName: args.lastName || fallbackName.lastName,
email: args.email,
phone: args.phone,
company: args.company,
source: args.source,
status: "lead",
lastActivityAt: now,
})
const conversation = await upsertConversationRecord(ctx, {
contactId: contact?._id,
title: type === "requestMachine" ? "Machine request" : "Website contact",
channel: "chat",
source: args.source || "website",
status: "open",
direction: "inbound",
startedAt: now,
lastMessageAt: now,
lastMessagePreview: args.message || args.intent,
summaryText: args.intent || args.service,
})
await ensureConversationParticipant(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
role: "contact",
displayName: `${args.firstName || fallbackName.firstName} ${args.lastName || fallbackName.lastName}`.trim(),
phone: args.phone,
email: args.email,
})
await upsertMessageRecord(ctx, {
conversationId: conversation._id,
contactId: contact?._id,
direction: "inbound",
channel: "chat",
source: args.source || "website",
messageType: type,
body: args.message,
status: "pending",
sentAt: now,
metadata: JSON.stringify({
intent: args.intent,
service: args.service,
}),
})
const leadId = await ctx.db.insert("leadSubmissions", {
type,
status: "pending",
idempotencyKey: args.idempotencyKey,
firstName: args.firstName || fallbackName.firstName,
lastName: args.lastName || fallbackName.lastName,
email: args.email,
phone: args.phone,
company: args.company,
intent: args.intent || args.service,
message: args.message,
source: args.source,
page: args.page,
url: args.url,
employeeCount: args.employeeCount,
machineType: args.machineType,
machineCount: args.machineCount,
serviceTextConsent: args.serviceTextConsent,
marketingTextConsent: args.marketingTextConsent,
consentVersion: args.consentVersion,
consentCapturedAt: args.consentCapturedAt,
consentSourcePage: args.consentSourcePage,
contactId: contact?._id,
conversationId: conversation?._id,
usesendStatus: "pending",
ghlStatus: "pending",
createdAt: now,
updatedAt: now,
})
return {
inserted: true,
leadId,
idempotencyKey: args.idempotencyKey,
tenantId: args.tenantSlug || args.host,
}
},
})
export const updateLeadSyncStatus = mutation({
args: {
leadId: v.id("leadSubmissions"),
usesendStatus: v.optional(leadSyncStatus),
ghlStatus: v.optional(leadSyncStatus),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const lead = await ctx.db.get(args.leadId)
if (!lead) {
throw new Error("Lead not found")
}
const usesendStatus = args.usesendStatus ?? lead.usesendStatus
const ghlStatus = args.ghlStatus ?? lead.ghlStatus
const status = deriveSubmissionStatus(usesendStatus, ghlStatus)
const now = Date.now()
await ctx.db.patch(args.leadId, {
usesendStatus,
ghlStatus,
status,
error: args.error,
deliveredAt:
status === "delivered" ? (lead.deliveredAt ?? now) : lead.deliveredAt,
updatedAt: now,
})
if (lead.contactId) {
await ctx.db.patch(lead.contactId, {
status: status === "delivered" ? "active" : "lead",
lastActivityAt: now,
updatedAt: now,
})
}
if (lead.conversationId) {
await ctx.db.patch(lead.conversationId, {
status: status === "failed" ? "archived" : "open",
lastMessageAt: now,
updatedAt: now,
})
}
return await ctx.db.get(args.leadId)
},
})
export const submitContact = action({
args: {
...baseLead,
},
handler: async (ctx, args) => {
const payload = {
firstName: args.firstName,
lastName: args.lastName,
email: args.email,
phone: args.phone,
company: args.company ?? "",
custom1: args.message ?? "",
custom2: args.source ?? "website",
custom3: args.page ?? "",
custom4: new Date().toISOString(),
custom5: args.url ?? "",
custom6: args.intent ?? "",
custom7: args.serviceTextConsent ? "Consented" : "Not Consented",
custom8: args.marketingTextConsent ? "Consented" : "Not Consented",
custom9: args.consentVersion ?? "",
custom10: args.consentCapturedAt ?? "",
custom11: args.consentSourcePage ?? "",
}
const result = await sendWebhook(
process.env.GHL_CONTACT_WEBHOOK_URL,
payload
)
await ctx.runMutation("leads:createLead", {
type: "contact",
...args,
usesendStatus: "skipped",
ghlStatus: result.success ? "synced" : "failed",
status: result.success ? "delivered" : "failed",
error: result.error,
})
return result
},
})
export const submitRequestMachine = action({
args: {
...baseLead,
employeeCount: v.string(),
machineType: v.string(),
machineCount: v.string(),
},
handler: async (ctx, args) => {
const payload = {
firstName: args.firstName,
lastName: args.lastName,
email: args.email,
phone: args.phone,
company: args.company ?? "",
custom1: args.employeeCount,
custom2: args.machineType,
custom3: args.machineCount,
custom4: args.message ?? "",
custom5: args.source ?? "website",
custom6: args.page ?? "",
custom7: new Date().toISOString(),
custom8: args.url ?? "",
custom9: args.serviceTextConsent ? "Consented" : "Not Consented",
custom10: args.marketingTextConsent ? "Consented" : "Not Consented",
custom11: args.consentVersion ?? "",
custom12: args.consentCapturedAt ?? "",
custom13: args.consentSourcePage ?? "",
custom14: "Free Consultation Request",
tags: ["Machine Request"],
}
const result = await sendWebhook(
process.env.GHL_REQUEST_MACHINE_WEBHOOK_URL,
payload
)
await ctx.runMutation("leads:createLead", {
type: "requestMachine",
...args,
usesendStatus: "skipped",
ghlStatus: result.success ? "synced" : "failed",
status: result.success ? "delivered" : "failed",
error: result.error,
})
return result
},
})