import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; import { ingestLead, isConvexConfigured, type LeadSyncStatus, updateLeadSyncStatus, } from "@/lib/convex"; import { isEmailConfigured, sendTransactionalEmail, TO_EMAIL } from "@/lib/email"; import { createGHLContact } from "@/lib/ghl"; import { createSmsConsentPayload, isValidConsentTimestamp } from "@/lib/sms-compliance"; export type LeadKind = "contact" | "request-machine"; type StorageStatus = "stored" | "deduped" | "skipped" | "failed"; type DeliveryChannel = "convex" | "email" | "ghl"; type SharedLeadFields = { firstName: string; lastName: string; email: string; phone: string; intent?: string; source?: string; page?: string; timestamp?: string; url?: string; confirmEmail?: string; serviceTextConsent: boolean; marketingTextConsent: boolean; consentVersion: string; consentCapturedAt: string; consentSourcePage: string; }; export type ContactLeadPayload = SharedLeadFields & { kind: "contact"; company?: string; message: string; }; export type RequestMachineLeadPayload = SharedLeadFields & { kind: "request-machine"; company: string; employeeCount: string; machineType: string; machineCount: string; message?: string; marketingConsent?: boolean; termsAgreement?: boolean; }; export type LeadPayload = ContactLeadPayload | RequestMachineLeadPayload; export type LeadSubmissionResponse = { success: boolean; message: string; deduped?: boolean; leadStored?: boolean; storageConfigured?: boolean; storageStatus?: StorageStatus; idempotencyKey?: string; deliveredVia?: DeliveryChannel[]; sync?: { usesendStatus: LeadSyncStatus; ghlStatus: LeadSyncStatus; }; warnings?: string[]; error?: string; }; type LeadSubmissionResult = { status: number; body: LeadSubmissionResponse; }; export type LeadSubmissionDeps = { storageConfigured: boolean; emailConfigured: boolean; ghlConfigured: boolean; ingest: typeof ingestLead; updateLeadStatus: typeof updateLeadSyncStatus; sendEmail: (to: string, subject: string, html: string, replyTo?: string) => Promise; createContact: typeof createGHLContact; logger: Pick; tenantSlug: string; tenantName: string; tenantDomains: string[]; }; const DEFAULT_TENANT_SLUG = process.env.CONVEX_TENANT_SLUG || "rocky_mountain_vending"; const DEFAULT_TENANT_NAME = process.env.CONVEX_TENANT_NAME || "Rocky Mountain Vending"; const DEFAULT_SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://rockymountainvending.com"; function normalizeHost(input: string) { return input .trim() .toLowerCase() .replace(/^https?:\/\//, "") .replace(/\/.*$/, "") .replace(/:\d+$/, "") .replace(/\.$/, ""); } function parseHostFromUrl(url?: string) { if (!url) { return ""; } try { return normalizeHost(new URL(url).host); } catch { return normalizeHost(url); } } function getConfiguredTenantDomains() { const siteDomain = normalizeHost(process.env.NEXT_PUBLIC_SITE_DOMAIN || ""); const siteUrlHost = parseHostFromUrl(process.env.NEXT_PUBLIC_SITE_URL); const fallbackHost = parseHostFromUrl(DEFAULT_SITE_URL); const domains = [siteDomain, siteUrlHost, fallbackHost].filter(Boolean); if (siteDomain && !siteDomain.startsWith("www.")) { domains.push(`www.${siteDomain}`); } return Array.from(new Set(domains.map(normalizeHost).filter(Boolean))); } function defaultDeps(): LeadSubmissionDeps { return { storageConfigured: isConvexConfigured(), emailConfigured: isEmailConfigured(), ghlConfigured: Boolean(process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID), ingest: ingestLead, updateLeadStatus: updateLeadSyncStatus, sendEmail: (to, subject, html, replyTo) => sendTransactionalEmail({ to, subject, html, replyTo }), createContact: createGHLContact, logger: console, tenantSlug: DEFAULT_TENANT_SLUG, tenantName: DEFAULT_TENANT_NAME, tenantDomains: getConfiguredTenantDomains(), }; } function escapeHtml(input: string) { return input .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function getSourceHost(request: Request) { const forwardedHost = request.headers.get("x-forwarded-host"); const host = request.headers.get("host"); return normalizeHost(forwardedHost || host || "rockymountainvending.com"); } function isRequestMachinePayload(body: Record) { return ( body.kind === "request-machine" || "employeeCount" in body || "machineType" in body || "machineCount" in body ); } function coerceString(input: unknown) { return typeof input === "string" ? input.trim() : ""; } function coerceBoolean(input: unknown) { return input === true || input === "true" || input === "on"; } function normalizeLeadPayload( body: Record, sourceHost: string, kindOverride?: LeadKind ): LeadPayload { const timestamp = coerceString(body.timestamp) || new Date().toISOString(); const page = coerceString(body.page); const consentPayload = createSmsConsentPayload({ serviceTextConsent: body.serviceTextConsent, marketingTextConsent: body.marketingTextConsent ?? body.marketingConsent, consentVersion: body.consentVersion, consentCapturedAt: body.consentCapturedAt ?? timestamp, consentSourcePage: body.consentSourcePage ?? page, }); const kind = kindOverride || (isRequestMachinePayload(body) ? "request-machine" : "contact"); const shared = { firstName: coerceString(body.firstName), lastName: coerceString(body.lastName), email: coerceString(body.email).toLowerCase(), phone: coerceString(body.phone), intent: coerceString(body.intent), source: coerceString(body.source) || "website", page, timestamp, url: coerceString(body.url) || `https://${sourceHost}`, confirmEmail: coerceString(body.confirmEmail), ...consentPayload, }; if (kind === "request-machine") { return { kind, ...shared, company: coerceString(body.company), employeeCount: String(body.employeeCount ?? "").trim(), machineType: coerceString(body.machineType), machineCount: String(body.machineCount ?? "").trim(), message: coerceString(body.message), marketingConsent: coerceBoolean(body.marketingConsent), termsAgreement: coerceBoolean(body.termsAgreement), }; } return { kind, ...shared, company: coerceString(body.company), message: coerceString(body.message), }; } function validateLeadPayload(payload: LeadPayload) { const requiredFields = [ ["firstName", payload.firstName], ["lastName", payload.lastName], ["email", payload.email], ["phone", payload.phone], ]; if (payload.kind === "contact") { requiredFields.push(["message", payload.message]); } else { requiredFields.push( ["company", payload.company], ["employeeCount", payload.employeeCount], ["machineType", payload.machineType], ["machineCount", payload.machineCount] ); } const missingFields = requiredFields .filter(([, value]) => !String(value).trim()) .map(([field]) => field); if (missingFields.length > 0) { return `Missing required fields: ${missingFields.join(", ")}`; } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(payload.email)) { return "Invalid email address"; } const phoneDigits = payload.phone.replace(/\D/g, ""); if (phoneDigits.length < 10 || phoneDigits.length > 15) { return "Invalid phone number"; } if (!payload.serviceTextConsent) { return "Service SMS consent is required"; } if (!payload.consentVersion.trim()) { return "Consent version is required"; } if (!payload.consentSourcePage.trim()) { return "Consent source page is required"; } if (!isValidConsentTimestamp(payload.consentCapturedAt)) { return "Consent timestamp is invalid"; } if (payload.kind === "contact") { if (payload.message.length < 10) { return "Message must be at least 10 characters"; } return null; } const employeeCount = Number(payload.employeeCount); if (!Number.isFinite(employeeCount) || employeeCount < 1 || employeeCount > 10000) { return "Invalid number of employees/people"; } const machineCount = Number(payload.machineCount); if (!Number.isFinite(machineCount) || machineCount < 1 || machineCount > 100) { return "Invalid number of machines"; } return null; } function buildLeadMessage(payload: LeadPayload) { const consentSummary = [ `Service SMS consent: ${payload.serviceTextConsent ? "yes" : "no"}`, `Marketing SMS consent: ${payload.marketingTextConsent ? "yes" : "no"}`, `Consent version: ${payload.consentVersion}`, `Consent captured at: ${payload.consentCapturedAt}`, `Consent source page: ${payload.consentSourcePage}`, ].join("\n"); if (payload.kind === "contact") { return [ payload.intent ? `Intent: ${payload.intent}` : "", payload.message, consentSummary, ] .filter(Boolean) .join("\n"); } return [ payload.message ? `Additional information: ${payload.message}` : "", payload.intent ? `Intent: ${payload.intent}` : "", `Company: ${payload.company}`, `People/employees: ${payload.employeeCount}`, `Machine type: ${payload.machineType}`, `Machine count: ${payload.machineCount}`, consentSummary, `Source: ${payload.source || "website"}`, `Page: ${payload.page || ""}`, `URL: ${payload.url || ""}`, ] .filter(Boolean) .join("\n"); } function buildIdempotencyKey(payload: LeadPayload, sourceHost: string) { const identity = [ payload.kind, sourceHost, payload.firstName, payload.lastName, payload.email, payload.phone, payload.company || "", buildLeadMessage(payload), ] .map((value) => value.trim().toLowerCase()) .join("|"); return createHash("sha256").update(identity).digest("hex"); } function buildEmailSubject(payload: LeadPayload) { const fullName = `${payload.firstName} ${payload.lastName}`.trim(); if (payload.kind === "request-machine") { return `[Rocky Mountain Vending] Machine request from ${fullName}`; } return `[Rocky Mountain Vending] Contact from ${fullName}`; } function buildAdminEmailHtml(payload: LeadPayload) { const common = `

Name: ${escapeHtml( `${payload.firstName} ${payload.lastName}`.trim() )}

Email: ${escapeHtml(payload.email)}

Phone: ${escapeHtml(payload.phone)}

${payload.company ? `

Company: ${escapeHtml(payload.company)}

` : ""} ${payload.intent ? `

Intent: ${escapeHtml(payload.intent)}

` : ""}

Source: ${escapeHtml(payload.source || "website")}

${payload.page ? `

Page: ${escapeHtml(payload.page)}

` : ""} ${payload.url ? `

URL: ${escapeHtml(payload.url)}

` : ""}

Service SMS consent: ${payload.serviceTextConsent ? "Yes" : "No"}

Marketing SMS consent: ${payload.marketingTextConsent ? "Yes" : "No"}

Consent version: ${escapeHtml(payload.consentVersion)}

Consent captured at: ${escapeHtml(payload.consentCapturedAt)}

Consent source page: ${escapeHtml(payload.consentSourcePage)}

`; if (payload.kind === "contact") { return ` ${common}

Message:

${escapeHtml(
        payload.message
      )}
`; } return ` ${common}

Employees/people: ${escapeHtml(payload.employeeCount)}

Machine type: ${escapeHtml(payload.machineType)}

Machine count: ${escapeHtml(payload.machineCount)}

${ payload.message ? `

Additional information:

${escapeHtml(
            payload.message
          )}
` : "" } `; } function buildConfirmationEmailHtml(payload: LeadPayload) { const firstName = escapeHtml(payload.firstName); const siteUrl = escapeHtml(DEFAULT_SITE_URL); const detail = payload.kind === "request-machine" ? "We received your free vending machine consultation request and will contact you within 24 hours." : "We received your message and will get back to you within 24 hours."; return `

Hi ${firstName},

${detail}

If you need anything else sooner, you can reply to this email or visit ${siteUrl}.

Rocky Mountain Vending

`; } function buildGhlTags(payload: LeadPayload) { return [ "website-lead", payload.kind === "request-machine" ? "machine-request" : "contact-form", ]; } export async function processLeadSubmission( payload: LeadPayload, sourceHost: string, deps: LeadSubmissionDeps = defaultDeps() ): Promise { if (payload.confirmEmail) { return { status: 200, body: { success: true, message: "Thanks! We will be in touch shortly.", storageConfigured: deps.storageConfigured, storageStatus: "skipped", sync: { usesendStatus: "skipped", ghlStatus: "skipped", }, }, }; } const validationError = validateLeadPayload(payload); if (validationError) { return { status: 400, body: { success: false, message: validationError, error: validationError, }, }; } const hasConfiguredSink = deps.storageConfigured || deps.emailConfigured || deps.ghlConfigured; if (!hasConfiguredSink) { deps.logger.error("No lead sinks are configured for Rocky Mountain Vending."); return { status: 503, body: { success: false, message: "Lead intake is not configured. Please try again later.", error: "Lead intake is not configured. Please try again later.", }, }; } const idempotencyKey = buildIdempotencyKey(payload, sourceHost); const fullName = `${payload.firstName} ${payload.lastName}`.trim(); const leadMessage = buildLeadMessage(payload); const warnings: string[] = []; let storageStatus: StorageStatus = deps.storageConfigured ? "failed" : "skipped"; let leadId: string | undefined; let usesendStatus: LeadSyncStatus = "skipped"; let ghlStatus: LeadSyncStatus = "skipped"; if (deps.storageConfigured) { try { const ingestResult = await deps.ingest({ host: sourceHost, tenantSlug: deps.tenantSlug, tenantName: deps.tenantName, tenantDomains: Array.from(new Set([...deps.tenantDomains, sourceHost])), source: payload.kind === "request-machine" ? "rocky_machine_request_form" : "rocky_contact_form", idempotencyKey, name: fullName, email: payload.email, company: payload.company || undefined, service: payload.kind === "request-machine" ? "machine-request" : "contact", message: leadMessage, }); leadId = ingestResult.leadId; if (!ingestResult.inserted) { storageStatus = "deduped"; return { status: 200, body: { success: true, message: "Thanks! We already received this submission.", deduped: true, leadStored: true, storageConfigured: deps.storageConfigured, storageStatus, idempotencyKey, deliveredVia: ["convex"], sync: { usesendStatus, ghlStatus }, }, }; } storageStatus = "stored"; } catch (error) { deps.logger.error("Failed to store Rocky lead in Convex:", error); storageStatus = "failed"; warnings.push("Lead storage failed."); } } if (deps.emailConfigured) { const emailResults = await Promise.allSettled([ deps.sendEmail(TO_EMAIL, buildEmailSubject(payload), buildAdminEmailHtml(payload), payload.email), deps.sendEmail( payload.email, "Thanks for contacting Rocky Mountain Vending", buildConfirmationEmailHtml(payload) ), ]); const failures = emailResults.filter((result) => result.status === "rejected"); usesendStatus = failures.length === 0 ? "sent" : "failed"; if (failures.length > 0) { warnings.push("Usesend delivery failed for one or more recipients."); } } else { deps.logger.warn("No email transport is configured; skipping Rocky email sync."); } if (deps.ghlConfigured) { try { const ghlResult = await deps.createContact({ email: payload.email, firstName: payload.firstName, lastName: payload.lastName, phone: payload.phone, company: payload.company || undefined, source: `${sourceHost}:${payload.kind}`, tags: buildGhlTags(payload), }); ghlStatus = ghlResult ? "synced" : "failed"; if (!ghlResult) { warnings.push("GHL contact creation returned no record."); } } catch (error) { ghlStatus = "failed"; warnings.push(`GHL sync error: ${String(error)}`); } } else { deps.logger.warn("GHL credentials incomplete; skipping Rocky GHL sync."); } if (leadId) { try { await deps.updateLeadStatus({ leadId, usesendStatus, ghlStatus, error: warnings.length > 0 ? warnings.join(" | ") : undefined, }); } catch (error) { deps.logger.error("Failed to update Rocky lead sync status:", error); } } const deliveredVia: DeliveryChannel[] = []; if (storageStatus === "stored") { deliveredVia.push("convex"); } if (usesendStatus === "sent") { deliveredVia.push("email"); } if (ghlStatus === "synced") { deliveredVia.push("ghl"); } if (deliveredVia.length === 0) { return { status: 500, body: { success: false, message: "We could not process your request right now. Please try again shortly.", error: "We could not process your request right now. Please try again shortly.", storageConfigured: deps.storageConfigured, storageStatus, idempotencyKey, sync: { usesendStatus, ghlStatus }, warnings, }, }; } return { status: 200, body: { success: true, message: payload.kind === "request-machine" ? "Your consultation request was submitted successfully." : "Your message was submitted successfully.", leadStored: storageStatus === "stored", storageConfigured: deps.storageConfigured, storageStatus, idempotencyKey, deliveredVia, sync: { usesendStatus, ghlStatus }, ...(warnings.length > 0 ? { warnings } : {}), }, }; } export async function handleLeadRequest(request: Request, kindOverride?: LeadKind) { try { const body = (await request.json()) as Record; const sourceHost = getSourceHost(request); const payload = normalizeLeadPayload(body, sourceHost, kindOverride); const result = await processLeadSubmission(payload, sourceHost); return NextResponse.json(result.body, { status: result.status }); } catch (error) { console.error("Lead API error:", error); return NextResponse.json( { success: false, message: "Internal server error.", error: "Internal server error.", }, { status: 500 } ); } }