724 lines
20 KiB
TypeScript
724 lines
20 KiB
TypeScript
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<unknown>
|
|
createContact: typeof createGHLContact
|
|
logger: Pick<typeof console, "warn" | "error">
|
|
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 {
|
|
const ghlSyncEnabled = String(process.env.ENABLE_GHL_SYNC || "")
|
|
.trim()
|
|
.toLowerCase() === "true"
|
|
|
|
return {
|
|
storageConfigured: isConvexConfigured(),
|
|
emailConfigured: isEmailConfigured(),
|
|
ghlConfigured:
|
|
ghlSyncEnabled &&
|
|
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, ">")
|
|
.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<string, unknown>) {
|
|
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<string, unknown>,
|
|
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 = `
|
|
<p><strong>Name:</strong> ${escapeHtml(
|
|
`${payload.firstName} ${payload.lastName}`.trim()
|
|
)}</p>
|
|
<p><strong>Email:</strong> ${escapeHtml(payload.email)}</p>
|
|
<p><strong>Phone:</strong> ${escapeHtml(payload.phone)}</p>
|
|
${payload.company ? `<p><strong>Company:</strong> ${escapeHtml(payload.company)}</p>` : ""}
|
|
${payload.intent ? `<p><strong>Intent:</strong> ${escapeHtml(payload.intent)}</p>` : ""}
|
|
<p><strong>Source:</strong> ${escapeHtml(payload.source || "website")}</p>
|
|
${payload.page ? `<p><strong>Page:</strong> ${escapeHtml(payload.page)}</p>` : ""}
|
|
${payload.url ? `<p><strong>URL:</strong> ${escapeHtml(payload.url)}</p>` : ""}
|
|
<p><strong>Service SMS consent:</strong> ${payload.serviceTextConsent ? "Yes" : "No"}</p>
|
|
<p><strong>Marketing SMS consent:</strong> ${payload.marketingTextConsent ? "Yes" : "No"}</p>
|
|
<p><strong>Consent version:</strong> ${escapeHtml(payload.consentVersion)}</p>
|
|
<p><strong>Consent captured at:</strong> ${escapeHtml(payload.consentCapturedAt)}</p>
|
|
<p><strong>Consent source page:</strong> ${escapeHtml(payload.consentSourcePage)}</p>
|
|
`
|
|
|
|
if (payload.kind === "contact") {
|
|
return `
|
|
${common}
|
|
<p><strong>Message:</strong></p>
|
|
<pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(
|
|
payload.message
|
|
)}</pre>
|
|
`
|
|
}
|
|
|
|
return `
|
|
${common}
|
|
<p><strong>Employees/people:</strong> ${escapeHtml(payload.employeeCount)}</p>
|
|
<p><strong>Machine type:</strong> ${escapeHtml(payload.machineType)}</p>
|
|
<p><strong>Machine count:</strong> ${escapeHtml(payload.machineCount)}</p>
|
|
${
|
|
payload.message
|
|
? `<p><strong>Additional information:</strong></p><pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(
|
|
payload.message
|
|
)}</pre>`
|
|
: ""
|
|
}
|
|
`
|
|
}
|
|
|
|
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 `<p>Hi ${firstName},</p>
|
|
<p>${detail}</p>
|
|
<p>If you need anything else sooner, you can reply to this email or visit <a href="${siteUrl}">${siteUrl}</a>.</p>
|
|
<p>Rocky Mountain Vending</p>`
|
|
}
|
|
|
|
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<LeadSubmissionResult> {
|
|
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<string, unknown>
|
|
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 }
|
|
)
|
|
}
|
|
}
|