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 188 189 190 191 192 193 194 195 196 197 | 1x 5x 160x 1x 32x 28x 3x 33x 33x 10x 10x 10x 34x 34x 1x 1x 1x 34x 34x 33x 3x 3x 2x 1x 1x 22x 21x 21x 18x 18x 18x 21x 12x | /**
* RRM landing-page submissions (F-21 §10.5).
*
* POST /api/internal/rrm/submissions — called server-to-server by the Go
* worker behind go.interioring.com/partners, never by a browser. It sits
* under the internal router, so the `X-Internal-API-Key` presence guard and
* `contextMiddleware` already ran; nothing here re-applies either.
*
* The response contract is fixed with the Go side: a well-formed body gets a
* 200 with a `whatsappUrl` EVEN WHEN the phone is refused, because the form
* must never show a real person an error that stops them reaching WhatsApp.
* The only non-200 is a malformed body, which no human can produce from the
* form.
*/
import { normalizeToE164 } from "@interioring/utils/validation/phone";
import type { Context } from "hono";
import { Hono } from "hono";
import { z } from "zod";
import type { getDb } from "../../db";
import { RRM_CTAS, RRM_LOCALES } from "../../db/schema/enums";
import { ValidationError } from "../../lib/errors";
import { logger } from "../../lib/logger";
import { handleError, success } from "../../lib/response";
import {
type NotifySignupInput,
notifySignup,
} from "../../lib/rrm/signup-notify";
import { recordSubmission } from "../../services/rrm/submission.service";
type Env = {
Bindings: CloudflareBindings;
Variables: {
db: ReturnType<typeof getDb>;
};
};
/**
* Longest user agent kept. Truncated rather than rejected: a browser with a
* long UA is still a person, and the column is diagnostic, not identity.
*/
const USER_AGENT_MAX = 512;
/**
* An optional text field. The Go side sends `""` for a field left blank, so
* an empty string has to read as "not given" — otherwise every blank firm
* name would be stored as an empty firm called "".
*/
function optionalText(max: number) {
return z
.string()
.trim()
.max(max)
.optional()
.transform((value) => (value ? value : undefined));
}
const submissionSchema = z
.object({
cta: z.enum(RRM_CTAS),
name: z.string().trim().min(1).max(120),
// Format is NOT validated here. The service normalises and a bad number
// still gets a 200 with a link; a 400 on a typo is exactly the error the
// contract forbids.
phone: z.string().trim().min(1).max(32),
firmName: optionalText(160),
contactName: optionalText(120),
contactPhone: optionalText(32),
contactProject: optionalText(500),
lang: z.enum(RRM_LOCALES),
consentVersion: z.string().trim().min(1).max(64),
// D3: the realtor's own assertion, not the referred contact's consent
// (obtained on the first call and recorded there). A wrong-typed value
// never fails the request — it just isn't "asserted".
contactConsentAsserted: z.boolean().optional().catch(undefined),
// Deliberately uncapped and untrimmed. A bot that fills the honeypot with
// ten kilobytes must get the same fake 200 as one that fills it with a
// word; a 400 would tell it which field gave it away.
hp: z.string().optional(),
ipHash: optionalText(128),
userAgent: z
.string()
.optional()
.transform((value) =>
value ? value.slice(0, USER_AGENT_MAX) : undefined,
),
// §1.3: format (trim/lowercase/`[a-z0-9_-]{1,64}`) is re-validated and
// silently dropped by the service — never rejected here, same reasoning
// as `phone` above. `.catch()` covers a wrong-typed value too.
sourceDetail: z.string().optional().catch(undefined),
})
.superRefine((body, ctx) => {
// A referral must name a number. The go worker enforces the same rule
// before us; this is the guarantee for any other caller.
if (body.cta === "have_contact" && !body.contactPhone?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["contactPhone"],
message: "contact_phone_required",
});
}
});
/** `handleError` has no ZodError branch, so validation failures are named here. */
function parseOrThrow<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.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);
}
async function readJson(c: Context<Env>): Promise<unknown> {
try {
return await c.req.json();
} catch {
throw new ValidationError("Request body must be valid JSON");
}
}
const rrmSubmissions = new Hono<Env>();
// POST /internal/rrm/submissions
rrmSubmissions.post("/submissions", async (c) => {
try {
const input = parseOrThrow(submissionSchema, await readJson(c));
// §2.3: a generous per-IP-hash bucket. No `ipHash` (local dev, or the
// hashing secret unset upstream) skips entirely rather than inventing a
// shared fallback key that would throttle every visitor into one
// bucket. A binding error fails open — a public form must never 500
// over its own rate limiter.
if (c.env.RL_RESOURCE && input.ipHash) {
try {
const { success: allowed } = await c.env.RL_RESOURCE.limit({
key: `rrm-submit:${input.ipHash}`,
});
if (!allowed) {
return c.json({ success: false, error: "rate_limited" }, 429);
}
} catch (err) {
logger.error("[RRM submissions] rate limit check failed:", err);
}
}
const result = await recordSubmission(input, {
db: c.get("db"),
tokenSecret: c.env.RRM_TOKEN_SECRET,
});
// `duplicateNote` names the earlier referrer — third-party information
// that must never reach the submitting agent's own browser. It is used
// below to build the operator email and stripped from the response.
const { duplicateNote, ...publicResult } = result;
// §3.1: one email per real sign-up — never for a rejected phone
// (nothing to tell an operator) and never for a filled honeypot (a bot
// gets the same 200 a human gets, but must never trigger a real
// notification). Fired inside `waitUntil` so the visitor's redirect is
// never delayed by the email provider.
if (
!input.hp &&
(result.outcome === "created" || result.outcome === "existing")
) {
const notifyInput: NotifySignupInput = {
cta: input.cta,
name: input.name,
firmName: input.firmName,
phoneE164: normalizeToE164(input.phone) ?? input.phone,
lang: input.lang,
contactName: input.contactName,
contactPhone: input.contactPhone,
contactProject: input.contactProject,
duplicateNote,
sourceDetail: input.sourceDetail,
};
// `c.executionCtx` itself throws when unset (some test harnesses),
// not just `.waitUntil` — so both the read and the call live inside
// the same try. Nothing downstream awaits this notification anyway.
try {
c.executionCtx.waitUntil(
notifySignup(c.env, c.executionCtx, notifyInput),
);
} catch {
/* executionCtx unavailable in tests */
}
}
return success(c, publicResult);
} catch (err) {
return handleError(c, err);
}
});
export default rrmSubmissions;
|