// @ts-nocheck import { action, mutation } from "./_generated/server" import { v } from "convex/values" function normalizePhone(value?: string | null) { const digits = String(value || "").replace(/\D/g, "") if (!digits) { return undefined } if (digits.length === 10) { return `+1${digits}` } if (digits.length === 11 && digits.startsWith("1")) { return `+${digits}` } if (digits.length >= 11) { return `+${digits}` } return undefined } 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 ) { 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 normalizedPhone = normalizePhone(args.phone) const leadId = await ctx.db.insert("leadSubmissions", { ...args, normalizedPhone, createdAt: now, updatedAt: now, deliveredAt: args.status === "delivered" ? now : undefined, }) if (normalizedPhone) { const displayName = `${args.firstName} ${args.lastName}`.trim() const existingProfile = await ctx.db .query("contactProfiles") .withIndex("by_normalizedPhone", (q) => q.eq("normalizedPhone", normalizedPhone) ) .unique() const patch = { displayName: displayName || existingProfile?.displayName, firstName: args.firstName || existingProfile?.firstName, lastName: args.lastName || existingProfile?.lastName, email: args.email || existingProfile?.email, company: args.company || existingProfile?.company, lastIntent: args.intent || existingProfile?.lastIntent, lastLeadOutcome: args.type, lastSummaryText: args.message || existingProfile?.lastSummaryText, source: args.source || existingProfile?.source, updatedAt: now, } if (existingProfile) { await ctx.db.patch(existingProfile._id, patch) } else { await ctx.db.insert("contactProfiles", { normalizedPhone, ...patch, createdAt: now, }) } } return leadId }, }) 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 normalizedPhone = normalizePhone(args.phone) 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, normalizedPhone, 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, usesendStatus: "pending", ghlStatus: "pending", createdAt: now, updatedAt: now, }) if (normalizedPhone) { const displayName = `${args.firstName || fallbackName.firstName} ${args.lastName || fallbackName.lastName}`.trim() const existingProfile = await ctx.db .query("contactProfiles") .withIndex("by_normalizedPhone", (q) => q.eq("normalizedPhone", normalizedPhone) ) .unique() const patch = { displayName: displayName || existingProfile?.displayName, firstName: args.firstName || fallbackName.firstName || existingProfile?.firstName, lastName: args.lastName || fallbackName.lastName || existingProfile?.lastName, email: args.email || existingProfile?.email, company: args.company || existingProfile?.company, lastIntent: args.intent || args.service || existingProfile?.lastIntent, lastLeadOutcome: type, lastSummaryText: args.message || existingProfile?.lastSummaryText, source: args.source || existingProfile?.source, updatedAt: now, } if (existingProfile) { await ctx.db.patch(existingProfile._id, patch) } else { await ctx.db.insert("contactProfiles", { normalizedPhone, ...patch, createdAt: 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, }) 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 }, })