All files / lib lead-input.ts

100% Statements 37/37
96.87% Branches 31/32
100% Functions 10/10
100% Lines 31/31

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 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187                                          14x       98x         795x                 70x       1368x 1368x 193x           14x 14x   14x             113x                                                                           114x 114x 15x 15x   15x                               74x         74x 592x   74x                     78x 78x 10x     68x 1x   67x                                                             25x     24x 15x 13x 12x    
// Lead-store input (#1085): the one shape every writer hands to
// `dal.landingLeads.upsert`, and the one place its enums are enforced.
//
// Drizzle text enums emit no CHECK constraint, so without this an unknown
// `entryType` would be stored as-is. Routes parse BEFORE their own primary
// write (booking, inquiry, referral), so bad input is a 400 with nothing
// written; `upsert` parses again so no future caller can skip it.
 
import { ATTRIBUTION_PARAMS } from "@interioring/utils/analytics/attribution";
import { LEAD_CONSENT_VERSIONS } from "@interioring/utils/constants/consent";
import { z } from "zod";
import {
	LEAD_BHK,
	LEAD_CONSENT_STATES,
	LEAD_ENTRY_TYPES,
	LEAD_OFFERS,
	PRICE_TEST_ARMS,
} from "../db/schema/enums";
import { ValidationError } from "./errors";
 
/** Name stored when a writer has none (consultation booking, WhatsApp link). */
export const LEAD_NAME_PLACEHOLDER = "Name not given";
 
/** A blank string means "not given", so `""` never lands in a column. */
function optionalText(max: number) {
	return z
		.string()
		.trim()
		.max(max)
		.nullish()
		.transform((value) => value || undefined);
}
 
/**
 * Attribution values come from ad URLs we don't control. An over-long one is
 * truncated rather than rejected: losing the lead over a long utm_content
 * would be the wrong trade.
 */
function clipped(max: number, lowerCase: boolean) {
	return z
		.string()
		.nullish()
		.transform((value) => {
			const trimmed = value?.trim().slice(0, max);
			if (!trimmed) return undefined;
			return lowerCase ? trimmed.toLowerCase() : trimmed;
		});
}
 
// UTMs are lower-cased so "Meta" and "meta" report as one source. Click ids
// are NOT: gclid/fbclid are case-sensitive and Google/Meta reject a mangled one.
const utm = clipped(100, true);
const clickId = clipped(255, false);
 
export const leadInputSchema = z.object({
	name: z.string().trim().min(1).max(120),
	/** E.164, e.g. `+919876543210` — the dedup key. */
	phone: z
		.string()
		.regex(/^\+[1-9]\d{6,14}$/, "phone must be E.164")
		.nullish()
		.transform((value) => value || undefined),
	email: optionalText(254),
	locality: optionalText(200),
	notes: optionalText(1000),
	source: z.string().trim().min(1).max(100),
	pagePath: optionalText(500),
	entryType: z.enum(LEAD_ENTRY_TYPES),
	offer: z.enum(LEAD_OFFERS).optional(),
	utmSource: utm,
	utmMedium: utm,
	utmCampaign: utm,
	utmContent: utm,
	utmTerm: utm,
	gclid: clickId,
	fbclid: clickId,
	intLink: clipped(100, false),
	sourceId: clickId,
	sourceType: clipped(50, false),
	ctwaClid: clickId,
	adHeadline: clipped(255, false),
	priceTestArm: z.enum(PRICE_TEST_ARMS).optional(),
	city: optionalText(100),
	consentState: z.enum(LEAD_CONSENT_STATES).optional(),
	consentVersion: optionalText(50),
	bhk: z.enum(LEAD_BHK).optional(),
	possessionMonth: z
		.string()
		.regex(/^\d{4}-(0[1-9]|1[0-2])$/, "possessionMonth must be YYYY-MM")
		.optional(),
	/** Touch only: the record this contact created elsewhere, e.g. `CONS-…`. */
	ref: optionalText(64),
});
 
export type LeadInput = z.input<typeof leadInputSchema>;
export type ParsedLeadInput = z.output<typeof leadInputSchema>;
 
/** `handleError` has no ZodError branch, so validation failures are named here. */
export function parseLeadInput(value: unknown): ParsedLeadInput {
	const result = leadInputSchema.safeParse(value);
	if (result.success) return result.data;
	const detail = result.error.issues
		.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`)
		.join("; ");
	throw new ValidationError(detail);
}
 
/**
 * The attribution fields a www/go. form sends, picked off a request body.
 * The UTM/click-id list is the shared one the browser captured with, so a new
 * click id is added in one place. Values stay `unknown` — `parseLeadInput` is
 * what validates them.
 *
 * It is a WHITELIST, not a copy: a browser can put anything in the body, and
 * anything not named here is dropped rather than reaching the lead row. That
 * is what stopped a public form forging the referral programme's
 * `partnerCode` while the programme existed, and it is why the test still
 * pushes an unexpected field through.
 */
export function leadFieldsFromBody(body: Record<string, unknown>) {
	const fields: Record<string, unknown> = {
		offer: body.offer,
		priceTestArm: body.priceTestArm,
		city: body.city,
	};
	for (const field of Object.values(ATTRIBUTION_PARAMS)) {
		fields[field] = body[field];
	}
	return fields;
}
 
/**
 * The website consent box. The browser sends `consent: true` and the version
 * of the text it showed (one of `LEAD_CONSENT_VERSIONS`); the server stamps
 * the time, so a client clock can't backdate it. Missing consent is a 400 —
 * no form lead without it.
 */
export function requireFormConsent(body: Record<string, unknown>) {
	const version =
		typeof body.consentVersion === "string" ? body.consentVersion.trim() : "";
	if (body.consent !== true || !version) {
		throw new ValidationError("consent is required");
	}
	// The stored version has to name text we actually showed someone.
	if (!LEAD_CONSENT_VERSIONS.includes(version)) {
		throw new ValidationError(`unknown consentVersion: ${version}`);
	}
	return { consentState: "form_given" as const, consentVersion: version };
}
 
/**
 * Whether an incoming consent state may replace the lead's current one.
 *
 * Leads merge on phone, and a web form cannot prove the person typing owns
 * the number. So:
 * - An opt-out is lifted only by `notice_shown`, which writers record only on
 *   an affirmative WhatsApp reply FROM that number. A form (`form_given`)
 *   cannot re-consent someone who said STOP; anyone can type a number.
 * - `notice_shown` never downgrades `form_given`: both are consent, and the
 *   ticked box is the stronger record.
 * - `partner_attested` only fills a blank. This is RETIRED-PROGRAMME LEGACY:
 *   the F-21 referral programme that wrote it is deleted, so after that
 *   removal nothing reaches this branch — it is unreachable BY CONSTRUCTION,
 *   not wrong, and it is kept deliberately. Deleting it is the dangerous
 *   option: the value still sits on production rows, and without the branch
 *   it falls through to `return true`, so if any writer ever reappeared a
 *   partner's attestation could overwrite a homeowner's `opted_out`. A
 *   retained guard costs one comparison; its absence costs an opt-out.
 *   `lead-input.test.ts` covers it so the unreachable line is not a coverage
 *   regression either.
 * - Otherwise the homeowner's latest action wins (STOP always applies).
 *
 * Whatever this decides, the touch records the incoming consent as given.
 */
export function consentApplies(
	current: string | null | undefined,
	incoming: string | undefined,
): boolean {
	if (!incoming) return false;
	// Retired F-21 legacy — unreachable by construction, kept so a legacy
	// value can never override an opt-out. See the doc comment above.
	if (incoming === "partner_attested") return !current;
	if (incoming === "form_given" && current === "opted_out") return false;
	if (incoming === "notice_shown" && current === "form_given") return false;
	return true;
}