Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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