All files / lib/partners notify.ts

100% Statements 30/30
100% Branches 22/22
100% Functions 3/3
100% Lines 26/26

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                                                                                                                                                                                              28x                         14x 2x   12x 12x             20x   20x         20x   18x   20x     20x       17x       16x         15x 15x 1x     1x     14x   14x                               14x                                   14x   13x   1x     1x     12x           2x      
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { TPL_RRM_PARTNER_REFERRAL_UPDATE } from "../../config/whatsapp-templates";
import type { Dal } from "../../dal";
import { type Partner, PartnersDal } from "../../dal/partners/partners.dal";
import { type Referral, ReferralsDal } from "../../dal/partners/referrals.dal";
import type * as schema from "../../db/schema";
import { WhatsAppAdapter } from "../communication/adapters/whatsapp.adapter";
import type {
	CommunicationQueueMessage,
	WhatsAppContent,
} from "../communication/types";
import { logger } from "../logger";
import type { ReferralState } from "./state";
import { statusLabel } from "./timeline";
 
/**
 * The partner's push channel — the one the app has been promising all along.
 *
 * `lib/partners/timeline.ts` says "We will let you know the moment they message
 * us" and the partner app repeats it, and until this file there was no partner
 * notification channel of any kind: the only thing we ever sent a partner was
 * their login OTP. Monitoring was pure pull — they found out by reopening the
 * app.
 *
 * ── Why a TEMPLATE, and why through the OTP's path ───────────────────────
 * A partner has no open 24-hour service window when a state changes: the last
 * person who messaged us was the HOMEOWNER, on a different number. So this
 * cannot be a free-form reply the way `whatsapp.routes.ts`'s homeowner
 * acknowledgement is — it has to be an approved template, sent exactly the way
 * `lib/communication/whatsapp-otp.ts` sends the login code: build the content,
 * hand it to `WhatsAppAdapter`, and let the adapter apply the non-prod safety
 * guard and record the message on the partner's thread.
 *
 * NOT through `services/rrm/gateway.service.ts`: that path's first act is to
 * look a PROSPECT up by id and every refusal writes a `send_blocked` row keyed
 * on one. A partner is not a prospect (most have no prospect row at all), so
 * every send would both fail and corrupt the recruitment funnel's numbers.
 *
 * The price of that, stated plainly because the template name looks like the
 * ladder's: the gateway's frequency caps and `POST /api/admin/rrm/halt` do not
 * apply here. An operator halting the recruitment campaign does NOT silence
 * these, and that is the intent — a halt is about not messaging strangers,
 * while this tells a partner what happened to their own money. The switches
 * that DO stop it are the partner's STOP (`notify_whatsapp`), suspension, and
 * the adapter's non-prod guard. If the owner ever wants the halt to cover this
 * channel too, it is one line at the top of the send below.
 *
 * ── The one rule this module has ─────────────────────────────────────────
 * IT NEVER THROWS. Every caller is an operator action or a webhook that has
 * already done the thing being announced — a WhatsApp outage must never turn a
 * successful verification into a 500, or stop the next message in a webhook
 * batch. Failures are logged and swallowed, here, once, so no call site has to
 * remember to.
 *
 * Until Meta approves `rrm_partner_referral_update_v1` every send fails at the
 * adapter and is logged as such. Nothing else breaks; the app still shows the
 * state, exactly as it did before this existed.
 */
 
/**
 * What just happened, from the partner's point of view.
 *
 * `payout_paid` is the one member that is not a referral state — the money
 * leaving is an event on the PAYOUT, not on the referral, but it is announced
 * per referral because that is the screen a partner asks "so what happened to
 * the ₹100 for Priya?" on.
 */
export type PartnerNotifyState = ReferralState | "payout_paid";
 
export type PartnerNotifyContext = {
	db: DrizzleD1Database<typeof schema>;
	env: CloudflareBindings;
	/**
	 * Optional, and passed straight through to the adapter — with it the send
	 * lands on the partner's own WhatsApp thread, which is where an operator
	 * looks when a partner says "I was never told". Without it the message
	 * still goes; only the record of it is missing.
	 */
	dal?: Dal;
};
 
export type PartnerNotifyArgs = {
	/**
	 * The partner row, or their id. Omit it entirely and it is read off the
	 * referral — a call site that already holds the row should pass it rather
	 * than pay for a second lookup.
	 */
	partner?: Partner | string;
	/** The referral row, or its id. */
	referral: Referral | string;
	state: PartnerNotifyState;
};
 
/** Meta rejects an empty template parameter, so every variable has a floor. */
function firstName(full: string | null | undefined, fallback: string): string {
	return full?.trim().split(/\s+/)[0] || fallback;
}
 
/**
 * The plain-language line, built from what the APP already says.
 *
 * `statusLabel` is the partner-facing vocabulary in `timeline.ts` — §6.3's, the
 * only status wording a partner ever sees. Reusing it is not tidiness: the
 * message and the screen it tells them to open have to agree, and a second
 * hand-written copy of "Confirmed — ₹100 approved" would drift the first time
 * one of them was edited.
 */
function whatHappened(state: PartnerNotifyState, contactName: string): string {
	if (state === "payout_paid") {
		return "Your payout has been sent to the UPI id you gave us.";
	}
	const { label, hint } = statusLabel(state, contactName);
	return `${label}. ${hint}`;
}
 
export async function notifyPartnerReferralUpdate(
	ctx: PartnerNotifyContext,
	args: PartnerNotifyArgs,
): Promise<void> {
	try {
		const referral =
			typeof args.referral === "string"
				? await new ReferralsDal(ctx.db).findById(args.referral)
				: args.referral;
		// Nothing to announce and nothing to name it after. A referral that has
		// been deleted between the trigger and this call is not an error.
		if (!referral) return;
 
		const partnerRef = args.partner ?? referral.partnerId;
		const partner =
			typeof partnerRef === "string"
				? await new PartnersDal(ctx.db).findById(partnerRef)
				: partnerRef;
		if (!partner) return;
 
		// Their own switch, and the one the STOP guard in
		// `services/rrm/inbound.service.ts` flips.
		if (!partner.notifyWhatsapp) return;
		// FR-F-7: a suspended partner is told why, once, by a person. Continuing
		// to send them referral updates while their account is frozen would be
		// the platform talking past its own suspension.
		if (partner.suspendedAt) return;
 
		// Same rule as the sign-up acknowledgement in `scheduler.service.ts`:
		// a message whose only call to action is a broken link is worse than no
		// message, and Meta rejects an empty parameter anyway.
		const url = ctx.env.PARTNER_APP_URL;
		if (!url) {
			logger.warn(
				"[partner-notify] PARTNER_APP_URL is not set — update not sent",
			);
			return;
		}
 
		const contactFirst = firstName(referral.contactName, "your contact");
 
		const content: WhatsAppContent = {
			templateName: TPL_RRM_PARTNER_REFERRAL_UPDATE,
			languageCode: "en",
			components: [
				{
					type: "body",
					parameters: [
						{ type: "text", text: firstName(partner.name, "there") },
						{ type: "text", text: contactFirst },
						{ type: "text", text: whatHappened(args.state, contactFirst) },
						{ type: "text", text: url },
					],
				},
			],
		};
 
		const message: CommunicationQueueMessage = {
			// Not queued, so nothing is written from this object but the
			// recipient and the content — the same shape the OTP sender uses.
			logId: 0,
			channel: "whatsapp",
			// Already digits-with-country-code (`partners.phone_norm`), which is
			// exactly what the Cloud API wants — no normalisation to redo.
			recipient: partner.phoneNorm,
			// `COMMUNICATION_EVENT_TYPES` is owned by the D1 schema and has no
			// partner member; adding one is a migration this workstream does not
			// own. With `logId: 0` nothing persists this field, so the closest
			// existing label stands in and the send is identified by its template
			// name wherever it is actually read.
			eventType: "reminder_due",
			content,
			transactional: true,
		};
 
		const result = await new WhatsAppAdapter().send(message, ctx.env, ctx.dal);
 
		if (result.status === "failed") {
			// The expected state of the world until Meta approves the template.
			logger.error(
				`[partner-notify] ${args.state} update to partner ${partner.id} failed: ${result.errorMessage}`,
			);
			return;
		}
 
		logger.info(
			`[partner-notify] ${args.state} update sent to partner ${partner.id} (provider: ${result.provider})`,
		);
	} catch (err) {
		// See the module comment: no notification may ever fail the action that
		// triggered it.
		logger.error("[partner-notify] update not sent:", err);
	}
}