457 lines
13 KiB
TypeScript
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
|
|
},
|
|
})
|