All files / routes/partner referrals.routes.ts

34.17% Statements 27/79
24% Branches 12/50
37.5% Functions 3/8
35.52% Lines 27/76

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 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292                                                    2x   2x                   5x 5x                                                 5x   5x     5x             2x 5x 5x   5x 5x   5x 5x   5x 5x             5x         5x                             5x                                         5x 5x                                       2x                         2x                                                                     2x                                                             2x 2x   2x                                                                                                          
import { Hono } from "hono";
import { z } from "zod";
import type { Dal } from "../../dal";
import { IllegalTransitionError } from "../../dal/partners/referrals.dal";
import { logger } from "../../lib/logger";
import { goOrigin } from "../../lib/partners/origin";
import type { ReferralState } from "../../lib/partners/state";
import { buildTimeline, statusLabel } from "../../lib/partners/timeline";
import { ipFingerprint } from "../../lib/partners/ip";
import { createReferral } from "../../services/partners/referral.service";
import { toProspectPhoneNorm } from "../../services/rrm/prospect-resolver.service";
import { serializeReferral } from "./me.routes";
 
/**
 * The partner's own referrals.
 *
 * Every route is scoped to `partnerId` from the session — never from the body.
 * REQ-ARCH-2: the client holds no business rules, so a partner who edits their
 * own client state cannot create a referral the API would refuse, and every
 * refusal carries the reason string the UI renders rather than one it invents.
 */
type Env = {
	Bindings: CloudflareBindings;
	Variables: { dal: Dal; partnerId: string };
};
 
const referrals = new Hono<Env>();
 
const createSchema = z.object({
	contactName: z.string().trim().min(1).max(120),
	contactPhone: z.string().trim().min(1).max(32),
	society: z.string().trim().max(160).optional(),
	config: z.string().trim().max(32).optional(),
	possessionBand: z.string().trim().max(32).optional(),
	mode: z.enum(["direct", "link"]).optional(),
});
 
async function readJson(c: { req: { json: () => Promise<unknown> } }) {
	try {
		return await c.req.json();
	} catch {
		return null;
	}
}
 
/**
 * The message the partner forwards.
 *
 * Built HERE, not in the client, for two reasons. It carries the referral code
 * that our webhook matches on, so a client that got it subtly wrong would
 * produce referrals that can never be credited — and the partner would be the
 * one who looks unreliable. And it is written in the first person as the
 * partner, so the wording is a brand decision rather than a frontend detail.
 *
 * It is still fully editable once WhatsApp opens: it is their chat, and
 * FR-P-3.4 says so.
 */
function whatsappForwardUrl(args: {
	origin: string;
	contactPhoneNorm: string;
	contactName: string;
	code: string;
	society?: string | null;
}): string {
	const where = args.society ? ` at ${args.society}` : "";
	const text =
		`Hi ${args.contactName}, I've arranged a free interior design estimate for your home${where}. ` +
		`No cost and no obligation — just open this and send them a message: ` +
		`${args.origin}/e/${args.code}`;
	return `https://wa.me/${args.contactPhoneNorm}?text=${encodeURIComponent(text)}`;
}
 
// POST /api/partner/referrals — add a number.
//
// Creates a `draft` and SENDS NOTHING TO ANYONE (FR-P-3.2). The homeowner
// hears from the partner, never from us.
referrals.post("/", async (c) => {
	const dal = c.get("dal");
	const partnerId = c.get("partnerId");
 
	const parsed = createSchema.safeParse(await readJson(c));
	Iif (!parsed.success) return c.json({ error: "bad_request" }, 400);
 
	const contactPhoneNorm = toProspectPhoneNorm(parsed.data.contactPhone);
	Iif (!contactPhoneNorm) return c.json({ error: "invalid_phone" }, 422);
 
	const partner = await dal.partners.findById(partnerId);
	Iif (!partner) return c.json({ error: "not_found" }, 404);
 
	// FR-F-6. Computed here, at the edge of the request, so nothing downstream
	// is ever handed an IP address. `INTERNAL_API_KEY` doubles as the salt, the
	// same way `apps/go` uses it: a dedicated binding nobody had set would make
	// the whole signal null in every environment, which is the failure mode
	// this codebase keeps producing.
	const { ipHash, ipPrefixHash } = await ipFingerprint(
		c.req.raw.headers,
		c.env.INTERNAL_API_KEY,
	);
 
	const result = await createReferral(
		{ db: dal.db },
		{
			partner,
			ipHash,
			ipPrefixHash,
			contactName: parsed.data.contactName,
			contactPhoneNorm,
			society: parsed.data.society ?? null,
			config: parsed.data.config ?? null,
			possessionBand: parsed.data.possessionBand ?? null,
			mode: parsed.data.mode ?? "direct",
		},
	);
 
	Iif (!result.ok) {
		// Each refusal carries what the UI needs to explain itself. FR-P-3.7 is
		// explicit that the client must not invent the number.
		if (result.reason === "cap_reached") {
			return c.json(
				{
					error: "cap_reached",
					window: result.window,
					limit: result.limit,
					used: result.used,
					remaining: 0,
				},
				429,
			);
		}
		if (result.reason === "self_referral") {
			return c.json({ error: "self_referral" }, 422);
		}
		return c.json({ error: "suspended", detail: result.detail }, 403);
	}
 
	const { referral } = result;
	return c.json(
		{
			referral: serializeReferral(referral),
			// Present even for a duplicate: the partner may still want to send
			// the estimate as a courtesy, and hiding the button would read as a
			// broken screen rather than as a rule.
			whatsappUrl: whatsappForwardUrl({
				origin: goOrigin(c.env.ENVIRONMENT),
				contactPhoneNorm: referral.contactPhoneNorm,
				contactName: referral.contactName,
				code: referral.code,
				society: referral.society,
			}),
			duplicate: result.duplicate,
		},
		201,
	);
});
 
// GET /api/partner/referrals — the partner's list.
referrals.get("/", async (c) => {
	const dal = c.get("dal");
	const rows = await dal.referrals.listForPartner(c.get("partnerId"));
	c.header("Cache-Control", "no-store");
	return c.json({
		referrals: rows.map((row) => ({
			...serializeReferral(row),
			...statusLabel(row.status as ReferralState, row.contactName),
		})),
	});
});
 
// GET /api/partner/referrals/:id — detail and the promise-tracker timeline.
referrals.get("/:id", async (c) => {
	const dal = c.get("dal");
	const partnerId = c.get("partnerId");
 
	const referral = await dal.referrals.findById(c.req.param("id"));
	// Scoped to the caller. A referral belonging to someone else is a 404, not
	// a 403 — a 403 would confirm the id exists.
	if (!referral || referral.partnerId !== partnerId) {
		return c.json({ error: "not_found" }, 404);
	}
 
	const events = await dal.referrals.eventsFor(referral.id);
	c.header("Cache-Control", "no-store");
 
	return c.json({
		referral: serializeReferral(referral),
		...statusLabel(referral.status as ReferralState, referral.contactName),
		// Built from this referral's own transitions only. The homeowner's
		// messages are never read here — see lib/partners/timeline.ts.
		timeline: buildTimeline(events, referral.contactName),
		whatsappUrl: whatsappForwardUrl({
			origin: goOrigin(c.env.ENVIRONMENT),
			contactPhoneNorm: referral.contactPhoneNorm,
			contactName: referral.contactName,
			code: referral.code,
			society: referral.society,
		}),
	});
});
 
// POST /api/partner/referrals/:id/forwarded — the partner tapped send.
//
// Idempotent by way of the state machine: a second call finds the referral
// already `forwarded` and the transition is refused as `unchanged`, which is
// success from the caller's point of view.
referrals.post("/:id/forwarded", async (c) => {
	const dal = c.get("dal");
	const referral = await dal.referrals.findById(c.req.param("id"));
	if (!referral || referral.partnerId !== c.get("partnerId")) {
		return c.json({ error: "not_found" }, 404);
	}
 
	if (referral.status === "forwarded") {
		return c.json({ referral: serializeReferral(referral) });
	}
 
	try {
		const updated = await dal.referrals.transition({
			referral,
			to: "forwarded",
			actorType: "partner",
			actorId: referral.partnerId,
		});
		return c.json({ referral: serializeReferral(updated) });
	} catch (error) {
		if (error instanceof IllegalTransitionError) {
			// A duplicate or an expired draft. Not an error the partner caused,
			// so it reports the current state rather than a failure.
			logger.warn(`[partner-referrals] ${error.message}`);
			return c.json({ referral: serializeReferral(referral) });
		}
		throw error;
	}
});
 
// POST /api/partner/referrals/:id/nudge — FR-P-5.2.
const NUDGE_COOLDOWN_MS = 48 * 60 * 60 * 1000;
const NUDGE_MAX = 2;
 
referrals.post("/:id/nudge", async (c) => {
	const dal = c.get("dal");
	const referral = await dal.referrals.findById(c.req.param("id"));
	if (!referral || referral.partnerId !== c.get("partnerId")) {
		return c.json({ error: "not_found" }, 404);
	}
 
	// Only worth nudging something the homeowner has not yet acted on. A
	// verified referral does not need chasing, and chasing it would be us
	// pushing a partner to message someone who is already talking to us.
	if (referral.status !== "forwarded") {
		return c.json({ error: "not_nudgeable" }, 409);
	}
 
	if (referral.nudgeCount >= NUDGE_MAX) {
		return c.json({ error: "nudge_limit", max: NUDGE_MAX }, 429);
	}
 
	const now = new Date();
	const last = referral.lastNudgeAt?.getTime() ?? 0;
	if (last && now.getTime() - last < NUDGE_COOLDOWN_MS) {
		return c.json(
			{
				error: "nudge_too_soon",
				nextAllowedAt: new Date(last + NUDGE_COOLDOWN_MS).toISOString(),
			},
			429,
		);
	}
 
	await dal.referrals.recordNudge(referral.id, now);
	await dal.referrals.addEvent({
		referralId: referral.id,
		type: "referral.nudged",
		actorType: "partner",
		actorId: referral.partnerId,
		now,
	});
 
	const where = referral.society ? ` at ${referral.society}` : "";
	const text =
		`Hi ${referral.contactName}, just checking you saw this — a free interior ` +
		`design estimate for your home${where}, no cost and no obligation: ` +
		`${goOrigin(c.env.ENVIRONMENT)}/e/${referral.code}`;
 
	return c.json({
		whatsappUrl: `https://wa.me/${referral.contactPhoneNorm}?text=${encodeURIComponent(text)}`,
		nudgeCount: referral.nudgeCount + 1,
		remaining: NUDGE_MAX - (referral.nudgeCount + 1),
	});
});
 
export default referrals;