608 lines
18 KiB
TypeScript
608 lines
18 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";
|
|
|
|
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;
|
|
source?: string;
|
|
page?: string;
|
|
timestamp?: string;
|
|
url?: string;
|
|
confirmEmail?: 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<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 {
|
|
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, ">")
|
|
.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 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),
|
|
source: coerceString(body.source) || "website",
|
|
page: coerceString(body.page),
|
|
timestamp: coerceString(body.timestamp) || new Date().toISOString(),
|
|
url: coerceString(body.url) || `https://${sourceHost}`,
|
|
confirmEmail: coerceString(body.confirmEmail),
|
|
};
|
|
|
|
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.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";
|
|
}
|
|
|
|
if (!payload.marketingConsent) {
|
|
return "Marketing consent is required";
|
|
}
|
|
|
|
if (!payload.termsAgreement) {
|
|
return "Terms agreement is required";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildLeadMessage(payload: LeadPayload) {
|
|
if (payload.kind === "contact") {
|
|
return payload.message;
|
|
}
|
|
|
|
return [
|
|
payload.message ? `Additional information: ${payload.message}` : "",
|
|
`Company: ${payload.company}`,
|
|
`People/employees: ${payload.employeeCount}`,
|
|
`Machine type: ${payload.machineType}`,
|
|
`Machine count: ${payload.machineCount}`,
|
|
`Marketing consent: ${payload.marketingConsent ? "yes" : "no"}`,
|
|
`Terms agreement: ${payload.termsAgreement ? "yes" : "no"}`,
|
|
`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>` : ""}
|
|
<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>` : ""}
|
|
`;
|
|
|
|
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>
|
|
<p><strong>Marketing consent:</strong> ${payload.marketingConsent ? "Yes" : "No"}</p>
|
|
<p><strong>Terms agreement:</strong> ${payload.termsAgreement ? "Yes" : "No"}</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,
|
|
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<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 }
|
|
);
|
|
}
|
|
}
|