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 leadId?: 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: coerceBoolean(body.serviceTextConsent), marketingTextConsent: coerceBoolean( body.marketingTextConsent ?? body.marketingConsent ), consentVersion: coerceString(body.consentVersion) || undefined, consentCapturedAt: coerceString(body.consentCapturedAt) || timestamp, consentSourcePage: coerceString(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, firstName: payload.firstName, lastName: payload.lastName, email: payload.email, phone: payload.phone, company: payload.company || undefined, service: payload.kind === "request-machine" ? "machine-request" : "contact", intent: payload.intent, page: payload.page, url: payload.url, message: leadMessage, employeeCount: payload.kind === "request-machine" ? payload.employeeCount : undefined, machineType: payload.kind === "request-machine" ? payload.machineType : undefined, machineCount: payload.kind === "request-machine" ? payload.machineCount : undefined, serviceTextConsent: payload.serviceTextConsent, marketingTextConsent: payload.marketingTextConsent, consentVersion: payload.consentVersion, consentCapturedAt: payload.consentCapturedAt, consentSourcePage: payload.consentSourcePage, }) 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, leadId, 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, leadId, 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, leadId, 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 } ) } }