Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
NEXTJS_ENV=development
GHL_ASSESSMENT_OBJECT_SCHEMA_KEY=custom_objects.automation_assessment
GHL_ASSESSMENT_CONTACT_ASSOCIATION_KEY=automation_assessments_submitted_by
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ GHL_API_KEY=
GHL_LOCATION_ID=
GHL_SOURCE="Tergion website lead form"
GHL_LEAD_TAGS="website-lead"
GHL_ASSESSMENT_OBJECT_SCHEMA_KEY="custom_objects.automation_assessment"
GHL_ASSESSMENT_CONTACT_ASSOCIATION_KEY="automation_assessments_submitted_by"

UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# testing
/coverage
/test-results/

# next.js
/.next/
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,16 @@ Copy `.env.example` to a local `.env.local` when credentials are available. Do n

## Current Site Scope

This site includes the public marketing foundation, implementation-aligned legal pages, examples scaffold, a 3-step lead form, `/api/leads`, `/api/health`, SEO basics, security headers, and provider boundaries.
This site includes the public marketing foundation, implementation-aligned legal pages, examples scaffold, a 4-step Quick Request, an 8-step Business Automation Assessment, `/api/leads`, `/api/health`, SEO basics, security headers, and provider boundaries.

## Request Modal Links

Canonical request modal links are centralized in `siteConfig.requestForms`:

- Quick Request: `https://tergion.com/contact?form=quick-request`
- Automation Assessment: `https://tergion.com/contact?form=automation-assessment`

Supported `form` values are `quick-request` and `automation-assessment`. Invalid values are ignored without showing an error. Only the form mode belongs in these URLs; never add PII such as names, email addresses, phone numbers, notes, or assessment answers. Closing the modal removes only the `form` parameter and preserves other query parameters, including UTM values.

## Integration Status

Expand Down
253 changes: 182 additions & 71 deletions app/api/leads/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
import {
automationAssessmentDuplicateMessage,
getAutomationAssessmentSuccessMessage,
} from "@/features/assessments/assessment.constants";
import {
commitGoHighLevelSubmission,
isGoHighLevelSubmissionCompleted,
markLeadSubmissionCompleted,
releaseLeadSubmission,
reserveLeadSubmission,
} from "@/features/leads/duplicate-check";
import {
sendInternalLeadNotification,
sendLeadConfirmationEmail,
} from "@/features/leads/email";
import { appendLeadToGoogleSheet } from "@/features/leads/google-sheets";
import { checkDuplicateLead } from "@/features/leads/duplicate-check";
import { checkLeadRateLimit } from "@/features/leads/rate-limit";
import { sendLeadToGoHighLevel } from "@/features/leads/gohighlevel";
import { isContactResolutionError } from "@/features/leads/gohighlevel-contact";
import { isAmbiguousGoHighLevelFailure } from "@/features/leads/gohighlevel-client";
import {
leadDuplicateMessage,
leadSuccessMessage,
} from "@/features/leads/lead.constants";
import { leadSubmissionSchema } from "@/features/leads/lead.schema";
import type { LeadRecord, LeadSubmission } from "@/features/leads/lead.types";
import { checkLeadRateLimit } from "@/features/leads/rate-limit";
import {
sendInternalLeadNotification,
sendLeadConfirmationEmail,
} from "@/features/leads/email";
import { sendLeadToGoHighLevel } from "@/features/leads/gohighlevel";
readLeadJsonBody,
validateLeadRequestHeaders,
} from "@/features/leads/request-guards";
import { checkLeadSpamSignals } from "@/features/leads/spam-check";
import { deriveSubmissionLeadId } from "@/features/leads/submission-id";
import { verifyTurnstileToken } from "@/features/leads/turnstile";
import type { LeadRecord } from "@/features/leads/lead.types";
import { env } from "@/lib/env";

function getRemoteIp(request: Request) {
Expand All @@ -39,43 +56,87 @@ function validationErrorResponse() {
);
}

function processingErrorResponse(status = 500) {
return Response.json(
{
ok: false,
message:
"We couldn't process this request automatically. Please verify your contact information or email contact@tergion.com.",
},
{ status },
);
}

function getSubmissionSuccessMessage(payload: LeadSubmission) {
return payload.submissionType === "automation_assessment"
? getAutomationAssessmentSuccessMessage(
payload.assessmentFollowUpPreference,
)
: leadSuccessMessage;
}

function getSubmissionDuplicateMessage(payload: LeadSubmission) {
return payload.submissionType === "automation_assessment"
? automationAssessmentDuplicateMessage
: leadDuplicateMessage;
}

export async function POST(request: Request) {
let rawPayload: unknown;
const headerValidation = validateLeadRequestHeaders(request);

try {
rawPayload = await request.json();
} catch {
return validationErrorResponse();
if (!headerValidation.ok) {
return Response.json(
{ ok: false, message: headerValidation.message },
{ status: headerValidation.status },
);
}

const parsed = leadSubmissionSchema.safeParse(rawPayload);
const body = await readLeadJsonBody(request);

if (!body.ok) {
return Response.json(
{ ok: false, message: body.message },
{ status: body.status },
);
}

const parsed = leadSubmissionSchema.safeParse(body.value);

if (!parsed.success) {
return validationErrorResponse();
}

const rateLimit = await checkLeadRateLimit(request);
const payload = parsed.data;
const spam = checkLeadSpamSignals(payload);

if (!rateLimit.allowed) {
if (spam.reasons.includes("honeypot-filled")) {
return Response.json({
ok: true,
message: getSubmissionSuccessMessage(payload),
});
}

if (!spam.passed) {
return Response.json(
{
ok: false,
message: "We could not accept the request right now. Please try again later.",
message:
"We could not accept the request right now. Please try again later.",
},
{ status: 429 },
{ status: 400 },
);
}

const payload = parsed.data;
const spam = checkLeadSpamSignals(payload);
const rateLimit = await checkLeadRateLimit(request);

if (!spam.passed) {
if (!rateLimit.allowed) {
return Response.json(
{
ok: false,
message: "We could not accept the request right now. Please try again later.",
message:
"We could not accept the request right now. Please try again later.",
},
{ status: 400 },
{ status: 429 },
);
}

Expand All @@ -94,68 +155,118 @@ export async function POST(request: Request) {
);
}

const duplicate = await checkDuplicateLead(payload.email, payload.phone);
const leadId = await deriveSubmissionLeadId(
payload.submissionType,
payload.submissionId,
);
const reservationResult = await reserveLeadSubmission(
payload.submissionType,
leadId,
payload.email,
payload.phone,
);

if (duplicate.duplicateLikely) {
if (
reservationResult.status === "completed" ||
reservationResult.status === "duplicate"
) {
return Response.json({
ok: true,
message: leadDuplicateMessage,
message: getSubmissionDuplicateMessage(payload),
});
}

const createdAt = new Date().toISOString();
const lead: LeadRecord = {
...payload,
leadId: crypto.randomUUID(),
createdAt,
status: "new",
security: {
turnstileVerified: turnstile.success,
turnstileConfigured: turnstile.configured,
spamScore: spam.score,
spamReasons: spam.reasons,
rateLimitReason: rateLimit.reason,
duplicateLikely: duplicate.duplicateLikely,
duplicateReason: duplicate.reason,
},
};
if (reservationResult.status === "unavailable") {
return processingErrorResponse(503);
}

if (reservationResult.status === "busy") {
return processingErrorResponse(409);
}

if (reservationResult.status !== "reserved") {
return processingErrorResponse(503);
}

const reservation = reservationResult.reservation;
let goHighLevelDurable = false;

try {
const goHighLevel = await sendLeadToGoHighLevel(lead);
const lead: LeadRecord = {
...payload,
leadId,
createdAt: new Date().toISOString(),
status: "new",
security: {
turnstileVerified: turnstile.success,
turnstileConfigured: turnstile.configured,
spamScore: spam.score,
spamReasons: spam.reasons,
rateLimitReason: rateLimit.reason,
duplicateLikely: false,
},
};
try {
const goHighLevelAlreadyCompleted =
await isGoHighLevelSubmissionCompleted(
payload.submissionType,
leadId,
);

if (!goHighLevelAlreadyCompleted) {
const goHighLevel = await sendLeadToGoHighLevel(lead);

if (
env.nodeEnv === "production" &&
(!goHighLevel.configured || !goHighLevel.ok)
) {
throw new Error("gohighlevel-production-delivery-unavailable");
}

}

await commitGoHighLevelSubmission(reservation);
goHighLevelDurable = true;
await appendLeadToGoogleSheet(lead);
await sendInternalLeadNotification(lead);
} catch (error) {
if (isContactResolutionError(error)) {
return processingErrorResponse(
error.category === "provider_unavailable" ? 503 : 409,
);
}

if (isAmbiguousGoHighLevelFailure(error)) {
goHighLevelDurable = true;
}

if (
env.nodeEnv === "production" &&
(!goHighLevel.configured || !goHighLevel.ok)
) {
throw new Error("gohighlevel-production-delivery-unavailable");
return processingErrorResponse();
}

await appendLeadToGoogleSheet(lead);
await sendInternalLeadNotification(lead);
} catch {
return Response.json(
{
ok: false,
message:
"We could not process the request right now. Please try again later.",
},
{ status: 500 },
);
}
try {
await markLeadSubmissionCompleted(payload.submissionType, leadId);
} catch {
return processingErrorResponse(503);
}

try {
await sendLeadConfirmationEmail(lead);
} catch {
console.warn("Lead confirmation email delivery failed", {
provider: "email",
stage: "route",
try {
await sendLeadConfirmationEmail(lead);
} catch {
console.warn("Lead confirmation email delivery failed", {
provider: "email",
stage: "route",
leadId: lead.leadId,
});
}

return Response.json({
ok: true,
message: getSubmissionSuccessMessage(payload),
leadId: lead.leadId,
});
} finally {
await releaseLeadSubmission(reservation, {
releaseIdentityReservation: !goHighLevelDurable,
});
}

return Response.json({
ok: true,
message: leadSuccessMessage,
leadId: lead.leadId,
});
}
Loading
Loading