All files / lib/communication whatsapp-otp.ts

100% Statements 17/17
100% Branches 12/12
100% Functions 1/1
100% Lines 17/17

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                                              17x                                       17x 12x         17x         2x     2x         15x 15x   17x                                 17x                 17x 17x   15x 2x 2x     8x       17x          
import { normalizeToE164 } from "@interioring/utils/validation/phone";
import { TPL_AUTH_OTP } from "../../config/whatsapp-templates";
import type { Dal } from "../../dal";
import { WhatsAppAdapter } from "./adapters/whatsapp.adapter";
import type { CommunicationQueueMessage, WhatsAppContent } from "./types";
 
/**
 * Send OTP via WhatsApp, routed through WhatsAppAdapter for safety guards.
 *
 * In non-prod environments, the adapter redirects non-allowlisted numbers
 * to the configured override number (see env-config.ts).
 * In local/dev + redirected, the adapter performs a dry run (no API call).
 *
 * Falls back to console logging if WHATSAPP_ACCESS_TOKEN is not configured.
 *
 * Returns delivery metadata so callers can log actualRecipient and provider.
 */
export async function sendWhatsAppOtp(
	env: CloudflareBindings,
	phoneNumber: string,
	code: string,
	dal?: Dal,
): Promise<{ actualRecipient: string; provider: string }> {
	const accessToken = env.WHATSAPP_ACCESS_TOKEN;
 
	// Print the code in local and dev, on EVERY path, before anything can fail.
	//
	// This used to happen only in the `!accessToken` branch below, which meant
	// it never fired for anyone whose .dev.vars had a token — i.e. anyone with
	// a working local setup. With a token present the send goes to the adapter,
	// which correctly dry-runs in non-prod and logs `[WA_DRY_RUN] ... message
	// NOT sent`, naming the recipient but never the code. So the message is not
	// delivered AND the code is invisible: the login is untestable in dev, which
	// is the environment the fallback existed to serve.
	//
	// Logged BEFORE the send rather than after, so a delivery failure still
	// leaves the developer able to log in — the code is already valid at this
	// point, since storeOtp ran before we were called.
	//
	// Fails CLOSED. Only the two environments the adapter itself treats as
	// dry-run (`local`, `dev`) print. `preview` carries real prospects, and an
	// unset ENVIRONMENT prints nothing rather than assuming it is safe — the
	// wrong way round would put one-time codes in production logs.
	if (env.ENVIRONMENT === "local" || env.ENVIRONMENT === "dev") {
		console.log(
			`[WhatsApp OTP] ${env.ENVIRONMENT.toUpperCase()} — Phone: ${phoneNumber} | Code: ${code}`,
		);
	}
 
	if (!accessToken) {
		// No credentials at all: there is nothing to send with, so the console
		// IS the transport. Unlike the block above this must log regardless of
		// environment — a production worker with no token is broken, and the
		// line is how that gets noticed.
		console.log(
			`[WhatsApp OTP] Phone: ${phoneNumber} | Code: ${code} | Provider: console (no WhatsApp credentials)`,
		);
		return { actualRecipient: phoneNumber, provider: "console" };
	}
 
	// WhatsApp Cloud API expects digits-only with country code (e.g., "919876543210").
	// Normalize first so any input format (E.164, formatted, raw) ends up consistent.
	const e164 = normalizeToE164(phoneNumber);
	const strippedPhone = e164 ? e164.slice(1) : phoneNumber.replace(/\D/g, "");
 
	const content: WhatsAppContent = {
		templateName: TPL_AUTH_OTP,
		languageCode: "en",
		components: [
			{
				type: "body",
				parameters: [{ type: "text", text: code }],
			},
			{
				type: "button",
				sub_type: "url",
				index: 0,
				parameters: [{ type: "text", text: code }],
			},
		],
	};
 
	const message: CommunicationQueueMessage = {
		logId: 0, // Not queued — no log entry yet
		channel: "whatsapp",
		recipient: strippedPhone,
		eventType: "otp_verification",
		content,
		transactional: true,
	};
 
	const adapter = new WhatsAppAdapter();
	const result = await adapter.send(message, env, dal);
 
	if (result.status === "failed") {
		console.error(`[WhatsApp OTP] Failed: ${result.errorMessage}`);
		throw new Error(`WhatsApp OTP delivery failed: ${result.errorMessage}`);
	}
 
	console.log(
		`[WhatsApp OTP] Sent to ${result.actualRecipient} (provider: ${result.provider}${result.externalId ? `, message_id: ${result.externalId}` : ""})`,
	);
 
	return {
		actualRecipient: result.actualRecipient,
		provider: result.provider,
	};
}