Rocky_Mountain_Vending/lib/server/contact-submission.ts

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
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 }
)
}
}