All files / routes/marketplace consultations.routes.ts

100% Statements 30/30
100% Branches 24/24
100% Functions 2/2
100% Lines 30/30

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                                              1x 1x       1x   1x     1x                 3x 3x       1x 9x 9x 9x     9x 1x   8x 8x 2x       6x     6x 1x       5x       1x             4x 4x 1x     3x 3x                           2x                   2x   2x   7x          
// 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).
 
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 { handleError, success } from "../../lib/response";
import { rateLimit } 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>();
 
// Same budget as the inquiry/landing-lead limiter: 5 per 10 minutes per IP.
const consultationRateLimit = rateLimit({
	windowMs: 10 * 60 * 1000,
	max: 5,
	devMax: 1000,
	message: "Too many submissions. Please try again in 10 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`,
			);
		}
 
		// 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,
		});
 
		// Best-effort email to support — never blocks or fails the response.
		fireInternalNotification(c, dal, {
			event: "consultation_lead_internal",
			bookingId: booking.id,
			reference: booking.reference,
			phone: booking.phone,
			details: booking.details,
			sourcePage: booking.sourcePage,
		});
 
		// 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;