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;
|