All files / services/partners engagement.service.ts

92.85% Statements 52/56
82.05% Branches 32/39
100% Functions 4/4
95.83% Lines 46/48

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 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317                                                                                                                                                                          73x 73x   73x 73x   25x 25x 18x 18x 18x       7x 7x       2x 2x     5x     2x     2x           3x                   3x 3x 3x                                                                                     48x     48x         46x 46x         41x     5x     3x               2x 2x 2x 2x                                                     20x 3x 3x 3x               1x                                                 23x   23x 2x 2x                         23x 23x                                     20x   3x       3x 3x      
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { PartnersDal } from "../../dal/partners/partners.dal";
import {
	IllegalTransitionError,
	type Referral,
	ReferralsDal,
} from "../../dal/partners/referrals.dal";
import type * as schema from "../../db/schema";
import { logger } from "../../lib/logger";
import { extractReferralCode } from "../../lib/partners/code";
import { isTerminalReferralState } from "../../lib/partners/state";
import { toProspectPhoneNorm } from "../rrm/prospect-resolver.service";
 
/**
 * `ref <CODE>` arriving on WhatsApp — the moment a referral becomes real.
 *
 * This is the ONLY path that can reach `engaged` (API-3). The partner tells us
 * they forwarded; the homeowner messaging us is what proves it, and it is also
 * what makes every later message to them free and compliant, because their
 * 24-hour service window opens on the same inbound.
 *
 * ── Why this lives outside inbound.service.ts ────────────────────────────
 * That module is the recruitment CRM's, and says so: its schema is frozen and
 * it does not own migrations. Partner-programme writes belong on the partner
 * side of the DM-9 line (`apps/api/src/db/schema/partners.ts`), so the caller
 * is a two-line branch there and the decisions are all here.
 */
 
export type EngagementContext = {
	db: DrizzleD1Database<typeof schema>;
	now?: Date;
	/**
	 * Supplied only when the caller can actually send. Absent in tests and in
	 * any environment without WhatsApp credentials, in which case the referral
	 * still engages and the acknowledgement is simply skipped — the state
	 * change is the thing that must not depend on a third party being up.
	 */
	acknowledge?: (args: {
		phoneNorm: string;
		contactName?: string;
		partnerName: string | null;
	}) => Promise<void>;
};
 
export type EngagementInput = {
	/** Bare digits from Meta, e.g. `919876543210`. */
	from: string;
	/** Normalised form, for comparison against what the partner supplied. */
	fromPhoneNorm: string;
	wamid: string;
	text: string | undefined;
	contactName?: string;
};
 
export type EngagementResult =
	/**
	 * Neither a code nor a LIVE referral for this number — a draft and a
	 * terminal referral both fall through. The caller must continue into the
	 * RRM path.
	 */
	| { matched: false }
	/**
	 * A well-formed code that is not ours. Still `false`, deliberately: a
	 * partner is an unknown-code sender until they enrol, so returning early
	 * would swallow their opener. Logged, not answered.
	 */
	| { matched: false; unknownCode: string }
	| { matched: true; referralId: string; engaged: boolean; created: boolean };
 
/**
 * Try to engage a referral from an inbound message.
 *
 * A code in the text wins. With no code we ask the sender's number instead —
 * see `engageByPhone`, because the prefilled text is a convenience a homeowner
 * can and does delete.
 *
 * Returns `matched: false` for anything that is neither, and the caller
 * continues into the recruitment path unchanged. Returns `matched: true` when
 * the message belonged to a referral — at which point the caller must NOT
 * continue, or a homeowner is enrolled as a recruitment prospect.
 */
export async function tryEngageReferral(
	ctx: EngagementContext,
	input: EngagementInput,
): Promise<EngagementResult> {
	const now = ctx.now ?? new Date();
	const referrals = new ReferralsDal(ctx.db);
 
	const code = extractReferralCode(input.text);
	if (!code) return engageByPhone(ctx, referrals, input, now);
 
	const existing = await referrals.findByCode(code);
	if (existing) {
		const engaged = await engage(referrals, existing, input, now);
		if (engaged) await acknowledge(ctx, existing.partnerId, input);
		return { matched: true, referralId: existing.id, engaged, created: false };
	}
 
	// Not a referral code — is it a partner's open share code (`/r/<code>`)?
	const partner = await new PartnersDal(ctx.db).findByShareCode(code);
	if (!partner) {
		// `extractReferralCode` already refused malformed input, so this is a
		// well-formed code that is not ours: a typo, or someone guessing. Worth
		// an ops line; NOT worth a reply, which would confirm the code space.
		logger.warn(`[partner-engagement] unknown referral code ${code}`);
		return { matched: false, unknownCode: code };
	}
 
	if (partner.suspendedAt) {
		// FR-H-6: a suspended partner's links stop attributing. Falls through
		// rather than engaging, so the homeowner is not stranded mid-flow.
		logger.warn(
			`[partner-engagement] share code ${code} belongs to suspended partner ${partner.id}`,
		);
		return { matched: false, unknownCode: code };
	}
 
	// A link-mode referral is BORN here. The partner never typed this contact —
	// we learn them from the inbound, which is the whole point of an open link
	// (FR-P-6.1). It pays identically to a direct referral (FR-P-6.3).
	const created = await referrals.create({
		partnerId: partner.id,
		contactName: input.contactName?.trim() || "WhatsApp contact",
		contactPhoneNorm: input.fromPhoneNorm,
		mode: "link",
		// Straight to `forwarded`: there was never a draft to forward. The
		// homeowner is holding the link, which is the thing `forwarded` means.
		status: "forwarded",
	});
 
	const engaged = await engage(referrals, created, input, now);
	Eif (engaged) await acknowledge(ctx, partner.id, input);
	return { matched: true, referralId: created.id, engaged, created: true };
}
 
/**
 * No code in the message — so ask the number instead.
 *
 * The prefilled text is a convenience, not a protocol. A homeowner taps the
 * partner's link, WhatsApp opens with `ref ABCD2345` ready to send, and they
 * clear it and type "hi" — which is exactly what a person does when they think
 * they are starting a conversation. Without this the referral sits at
 * `forwarded` for ever: the partner reads "waiting for them to message us"
 * about someone who already did, and the operator has no verify path for a
 * conversation that is really happening.
 *
 * ONLY `forwarded` engages. That state is the partner's own claim that this
 * number was handed the link, which is what makes a bare "hi" from it evidence
 * of anything at all. A `draft` has no such claim — the heal in `engage()`
 * needs the code as proof and there is none here.
 *
 * ── Why a live referral still MATCHES without engaging ────────────────────
 * A number sitting at `engaged` or beyond is a homeowner, provably. Falling
 * through for their second message would hand them to the recruitment path,
 * and `inbound.service.ts` only gates ENROLMENT on `looksLikePartnerMessage`
 * — an existing `rrm_prospects` row (a CP-list import, or a partner buying
 * their own home) gets `markReplied` on ANY inbound text. That is the exact
 * failure this module exists to prevent, so every live state returns
 * `matched: true, engaged: false`, mirroring what the code path already does
 * for a repeat message.
 *
 * `draft` and the terminal states are the deliberate exceptions: neither is a
 * live referral, and a converted or lost homeowner may legitimately be a
 * partner recruit now. Those fall through exactly as before.
 */
async function engageByPhone(
	ctx: EngagementContext,
	referrals: ReferralsDal,
	input: EngagementInput,
	now: Date,
): Promise<EngagementResult> {
	// wa_id arrives as bare digits ('91XXXXXXXXXX'); the repo's one normaliser
	// needs the '+' to read the country code, same as the inbound path does it.
	// Recomputed rather than trusting `input.fromPhoneNorm`, which the caller
	// falls back to `""` on — and `findFirstReferralOf("")` would match rows.
	const phoneNorm = toProspectPhoneNorm(
		input.from.startsWith("+") ? input.from : `+${input.from}`,
	);
	if (!phoneNorm) return { matched: false };
 
	// First referrer wins, and the DAL already excludes duplicate/rejected/
	// expired — so this is the referral that would be paid, not merely one of
	// the rows carrying the number.
	const referral = await referrals.findFirstReferralOf(phoneNorm);
	if (
		!referral ||
		referral.status === "draft" ||
		isTerminalReferralState(referral.status)
	) {
		return { matched: false };
	}
 
	if (referral.status !== "forwarded") {
		// Live, but already past `forwarded`. Nothing to write — the message is
		// still theirs, so the caller must not continue into recruitment.
		return {
			matched: true,
			referralId: referral.id,
			engaged: false,
			created: false,
		};
	}
 
	const withNorm = { ...input, fromPhoneNorm: phoneNorm };
	const engaged = await engage(referrals, referral, withNorm, now);
	Eif (engaged) await acknowledge(ctx, referral.partnerId, withNorm);
	return { matched: true, referralId: referral.id, engaged, created: false };
}
 
/**
 * The one automatic message a homeowner gets.
 *
 * Free-form, not a template, and that is what makes it possible at all: the
 * homeowner messaged us moments ago, so their 24-hour service window is open
 * (`whatsapp.routes.ts` stamps `last_customer_message_at` before this branch
 * runs). A free-form in-window reply needs no Meta template approval and costs
 * nothing against the ceiling.
 *
 * ── Why not `sendToProspect` ─────────────────────────────────────────────
 * `services/rrm/gateway.service.ts` is the RRM ladder's only send path, and
 * its first check looks a prospect up by id. A homeowner has none — so it
 * would return `error` AND write a `send_blocked` row keyed on an id that
 * belongs to no prospect, into the very table the ramp and funnel metrics
 * aggregate. Silently wrong numbers, which is worse than a failed send.
 *
 * NEVER fails the engagement. The referral is already `engaged` and the
 * partner's dashboard already says so; a WhatsApp outage must not undo that.
 */
async function acknowledge(
	ctx: EngagementContext,
	partnerId: string,
	input: EngagementInput,
): Promise<void> {
	if (!ctx.acknowledge) return;
	try {
		const partner = await new PartnersDal(ctx.db).findById(partnerId);
		await ctx.acknowledge({
			phoneNorm: input.fromPhoneNorm,
			contactName: input.contactName,
			// First name only. The homeowner knows who referred them; the full
			// name and firm are the partner's, not ours to broadcast.
			partnerName: partner?.name?.trim().split(/\s+/)[0] ?? null,
		});
	} catch (error) {
		logger.error("[partner-engagement] acknowledgement failed", error);
	}
}
 
/**
 * Move a referral to `engaged`, healing the one state gap that can legitimately
 * block it.
 *
 * `draft → engaged` is illegal, and reachable: `POST /:id/forwarded` is a
 * CLIENT-reported signal fired just before the browser hands off to WhatsApp,
 * so an Android app-switch that discards the page loses it. The homeowner then
 * holds a code for a referral we still believe is a draft.
 *
 * They could only have got that code from the partner, so their message IS the
 * evidence the forward happened. We record it as such — `draft → forwarded →
 * engaged`, both events written truthfully — rather than adding a
 * `draft → engaged` edge that would let the timeline skip a step that really
 * occurred.
 */
async function engage(
	referrals: ReferralsDal,
	referral: Referral,
	input: EngagementInput,
	now: Date,
): Promise<boolean> {
	let current = referral;
 
	if (current.status === "draft") {
		try {
			current = await referrals.transition({
				referral: current,
				to: "forwarded",
				actorType: "system",
				payload: { inferredFrom: "homeowner_message", wamid: input.wamid },
				now,
			});
		} catch (error) {
			if (!(error instanceof IllegalTransitionError)) throw error;
			return false;
		}
	}
 
	try {
		await referrals.transition({
			referral: current,
			to: "engaged",
			// Only a homeowner-initiated message can reach `engaged` (API-3).
			actorType: "homeowner",
			payload: {
				wamid: input.wamid,
				// FR-V-1 makes "replied from the referred number" one of the five
				// VERIFICATION slots, not a condition of engagement — households
				// share phones, and the wife often gets the link while the husband
				// messages. Recorded as a fact for the operator, never a block.
				fromMatchedReferral: input.fromPhoneNorm === current.contactPhoneNorm,
			},
			// `opted_out` is deliberately NOT released here. state.ts would let a
			// homeowner-initiated act undo it, and this IS one — but sending a
			// referral code is not an expressed intent to reverse a DPDP opt-out.
			// The transition is refused below and logged.
			now,
		});
		return true;
	} catch (error) {
		Iif (!(error instanceof IllegalTransitionError)) throw error;
		// `unchanged` is a redelivery and is success. `terminal`/`illegal` mean
		// the referral has moved on or was closed — neither is the homeowner's
		// fault and neither should fail the webhook.
		logger.warn(`[partner-engagement] ${error.message}`);
		return false;
	}
}