Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | 3x 3x 31x 2x 29x 29x 1x 28x 28x 1x 27x 27x 27x 25x 25x 2x 23x 25x 25x 2x 2x 1x 1x 24x 1x 23x 31x 31x 3x 20x 20x 9x 8x 1x 9x 3x 25x 25x 19x | // Public Landing-Page Lead Routes (API Key Protected)
//
// Leads from the www forms that have no pro attached: get-matched and the
// calculator gate. Since #1085 every one goes through the lead store: a
// returning phone adds a touch instead of a second lead, and support is
// emailed only on first contact. go.'s quote check reaches the same core
// through routes/internal/landing-leads.routes.ts.
import {
customerNameErrorMessage,
normalizeCustomerName,
validateCustomerName,
} from "@interioring/utils/validation/customer-name";
import { isValidEmail } from "@interioring/utils/validation/email";
import { parseIndianPhone } from "@interioring/utils/validation/phone";
import { type Context, Hono } from "hono";
import type { Dal } from "../../dal";
import { ValidationError } from "../../lib/errors";
import { fireInternalNotification } from "../../lib/internal-notifications";
import {
leadFieldsFromBody,
parseLeadInput,
requireFormConsent,
} from "../../lib/lead-input";
import { handleError, success } from "../../lib/response";
import { leadFormRateLimit } from "../../middleware/rate-limit.middleware";
type Env = {
Bindings: CloudflareBindings;
Variables: {
dal: Dal;
};
};
const landingLeads = new Hono<Env>();
// 6 per 3 minutes per visitor in production (see leadFormRateLimit).
const landingLeadRateLimit = leadFormRateLimit(
"Too many submissions. Please try again in 3 minutes.",
);
/**
* Validate a form lead, write it through the lead store, and email support on
* first contact. Shared by this route and the internal go. route; throws
* `ValidationError` for the caller's `handleError`.
*/
export async function submitLandingLead(c: Context, dal: Dal, body: unknown) {
if (!body || typeof body !== "object") {
throw new ValidationError("Request body must be a JSON object");
}
const b = body as Record<string, unknown>;
if (!b.name) {
throw new ValidationError("name is required");
}
const nameError = validateCustomerName(String(b.name));
if (nameError) {
throw new ValidationError(customerNameErrorMessage(nameError));
}
const name = normalizeCustomerName(String(b.name));
// Contact rule: at least one of phone/email must be present and valid.
// A provided-but-invalid value is a hard error (never silently dropped) —
// otherwise a typo'd phone could produce an unreachable lead.
let phone: string | null = null;
if (b.phone) {
const parsed = parseIndianPhone(String(b.phone));
if (!parsed || parsed.type !== "mobile") {
throw new ValidationError(
"phone must be a valid 10-digit Indian mobile number",
);
}
phone = parsed.e164;
}
let email: string | null = null;
if (b.email) {
const trimmed = String(b.email).trim();
if (trimmed.length > 254 || !isValidEmail(trimmed)) {
throw new ValidationError("email must be a valid email address");
}
email = trimmed;
}
if (!phone && !email) {
throw new ValidationError(
"Provide at least one contact: a valid 10-digit mobile number or an email address",
);
}
// A public form is a form or the calculator. Partner and WhatsApp entry
// types come only from their own writers, never from a browser.
const source = b.source ? String(b.source) : "landing";
const entryType =
b.entryType ?? (source === "cost-calculator" ? "calculator" : "form");
if (entryType !== "form" && entryType !== "calculator") {
throw new ValidationError("entryType must be form or calculator");
}
// Length caps, enums and attribution are enforced by parseLeadInput.
const input = parseLeadInput({
name,
phone,
email,
locality: b.locality,
notes: b.notes,
source,
pagePath: b.pagePath,
entryType,
...leadFieldsFromBody(b),
...requireFormConsent(b),
});
const { lead } = await dal.landingLeads.upsert(input);
// Best-effort email to support, once per person: the first time support
// would hear of them. Not "first time the store sees the phone" — an
// inquiry or a partner referral may have written the lead without telling
// support. Never blocks the response. The lead is marked only once the
// email actually went out, so a failed send is retried on the next contact.
if (!lead.supportNotifiedAt) {
fireInternalNotification(
c,
dal,
{
event: "landing_lead_internal",
leadId: lead.id,
name: lead.name,
phone: lead.phone,
email: lead.email,
locality: lead.locality,
notes: lead.notes,
source: lead.source,
entryType: lead.entryType,
utmSource: lead.utmSource,
utmMedium: lead.utmMedium,
utmCampaign: lead.utmCampaign,
},
() => dal.landingLeads.markSupportNotified(lead.id),
);
}
// No lead id: a public form must not reveal whether a phone was already
// in the store (a repeat contact would otherwise get the same id back).
return success(c, { received: true }, 201);
}
// Submit a landing-page lead
landingLeads.post("/", landingLeadRateLimit, async (c) => {
try {
return await submitLandingLead(c, c.get("dal"), await c.req.json());
} catch (error) {
return handleError(c, error);
}
});
export default landingLeads;
|