All files / routes/marketplace consultations.routes.ts

100% Statements 39/39
100% Branches 30/30
100% Functions 3/3
100% Lines 38/38

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                                                              2x 2x       2x   2x     2x           8x 8x       2x 15x 15x 15x     15x 1x   14x 14x 2x       12x     12x 1x       11x       1x             10x                               15x 9x 1x     8x 8x                                   7x 7x 7x       6x   1x           7x 7x                         1x       15x   15x   8x          
// Public Free-Consultation Booking Routes (API Key Protected)
//
// Bookings from the standalone /book-consultation page on the marketplace.
// Like /landing-leads these carry no proId — the support team is emailed the
// details and coordinates the call manually. A homeowner may have only one
// active consultation at a time (PRD P0). Each booking is also a lead-store
// touch (#1085), matched by phone, with the CONS- reference on the touch.
 
import { parseIndianPhone } from "@interioring/utils/validation/phone";
import { Hono } from "hono";
import type { Dal } from "../../dal";
import { fireConsultationConfirmation } from "../../lib/communication/whatsapp-consultation";
import { ConflictError, ValidationError } from "../../lib/errors";
import { fireInternalNotification } from "../../lib/internal-notifications";
import {
	LEAD_NAME_PLACEHOLDER,
	leadFieldsFromBody,
	parseLeadInput,
	requireFormConsent,
} from "../../lib/lead-input";
import { logger } from "../../lib/logger";
import { handleError, success } from "../../lib/response";
import { leadFormRateLimit } from "../../middleware/rate-limit.middleware";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		dal: Dal;
	};
};
 
const DETAILS_MAX_LENGTH = 1000;
const SOURCE_PAGE_MAX_LENGTH = 500;
 
// PRD copy shown when a phone already has an active consultation request.
const ACTIVE_CONSULTATION_MESSAGE =
	"You already have a consultation request in progress. Our team will be in touch shortly.";
 
const consultations = new Hono<Env>();
 
// 6 per 3 minutes per visitor in production (see leadFormRateLimit).
const consultationRateLimit = leadFormRateLimit(
	"Too many submissions. Please try again in 3 minutes.",
);
 
// Mint a human-facing booking reference, e.g. CONS-A1B2C3D4.
function generateReference(): string {
	const slug = crypto.randomUUID().replace(/-/g, "").slice(0, 8).toUpperCase();
	return `CONS-${slug}`;
}
 
// Submit a free-consultation booking
consultations.post("/", consultationRateLimit, async (c) => {
	try {
		const dal = c.get("dal");
		const body = await c.req.json();
 
		// Phone — required, must be a valid Indian mobile (WhatsApp/SMS delivery).
		if (!body.phone) {
			throw new ValidationError("phone is required");
		}
		const parsed = parseIndianPhone(String(body.phone));
		if (!parsed || parsed.type !== "mobile") {
			throw new ValidationError(
				"phone must be a valid 10-digit Indian mobile number",
			);
		}
		const phone = parsed.e164;
 
		// Details — optional free text.
		if (body.details && String(body.details).length > DETAILS_MAX_LENGTH) {
			throw new ValidationError(
				`details must be ${DETAILS_MAX_LENGTH} characters or fewer`,
			);
		}
		if (
			body.sourcePage &&
			String(body.sourcePage).length > SOURCE_PAGE_MAX_LENGTH
		) {
			throw new ValidationError(
				`sourcePage must be ${SOURCE_PAGE_MAX_LENGTH} characters or fewer`,
			);
		}
 
		// Lead-store input is checked before anything is written, so a missing
		// consent or an unknown enum is a 400 with no booking left behind.
		const leadInput = parseLeadInput({
			// The form asks for no name; the lead store swaps this placeholder
			// for a real name the first time another form supplies one.
			name: LEAD_NAME_PLACEHOLDER,
			phone,
			notes: body.details,
			source: "consultation",
			pagePath: body.sourcePage,
			entryType: "form",
			...leadFieldsFromBody(body),
			offer: body.offer ?? "general",
			...requireFormConsent(body),
		});
 
		// One active consultation per phone (PRD P0).
		const activeCount =
			await dal.consultationBookings.countActiveByPhone(phone);
		if (activeCount >= 1) {
			throw new ConflictError(ACTIVE_CONSULTATION_MESSAGE);
		}
 
		const reference = generateReference();
		const booking = await dal.consultationBookings.create({
			reference,
			phone,
			details: body.details ? String(body.details).trim() : null,
			// Trusted homeowner id from the marketplace SSR proxy (already
			// session-validated there), when the booker is logged in.
			homeownerId:
				body.homeownerId && typeof body.homeownerId === "string"
					? body.homeownerId
					: null,
			sourcePage: body.sourcePage ? String(body.sourcePage) : null,
		});
 
		// The booking is the primary record: a lead-store failure is logged, not
		// surfaced, so the homeowner still gets their reference.
		// The booking email below goes to support, so once it is sent support
		// has heard of this person and a later get-matched form won't email
		// them again. Marked only after a successful send (#1085 QA defect 2).
		let leadToMark: number | null = null;
		try {
			const { lead } = await dal.landingLeads.upsert({
				...leadInput,
				ref: booking.reference,
			});
			if (!lead.supportNotifiedAt) leadToMark = lead.id;
		} catch (err) {
			logger.error("[consultations] lead store write failed:", err);
		}
 
		// Best-effort email to support — never blocks or fails the response.
		// Sent for every booking, even from a known lead: a booking needs
		// someone to schedule the call (#1085 decision).
		const markId = leadToMark;
		fireInternalNotification(
			c,
			dal,
			{
				event: "consultation_lead_internal",
				bookingId: booking.id,
				reference: booking.reference,
				phone: booking.phone,
				details: booking.details,
				sourcePage: booking.sourcePage,
			},
			markId === null
				? undefined
				: () => dal.landingLeads.markSupportNotified(markId),
		);
 
		// Best-effort WhatsApp confirmation to the homeowner.
		fireConsultationConfirmation(c, dal, booking.phone, booking.reference);
 
		return success(c, { id: booking.id, reference: booking.reference }, 201);
	} catch (error) {
		return handleError(c, error);
	}
});
 
export default consultations;