Rocky_Mountain_Vending/lib/server/contact-submission.ts

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, "&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 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 }
);
}
}