// @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 ) { 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 }, })