All files / lib/communication whatsapp-consultation.ts

78.94% Statements 15/19
100% Branches 14/14
33.33% Functions 1/3
78.94% Lines 15/19

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                                                                                  7x   7x   7x 2x     2x             5x   5x                     5x                 5x 5x   5x 2x     2x             3x       7x                                                                  
import type { Context } from "hono";
import { TPL_CONSULTATION_CONFIRMATION } from "../../config/whatsapp-templates";
import type { Dal } from "../../dal";
import { WhatsAppAdapter } from "./adapters/whatsapp.adapter";
import type {
	AdapterErrorCode,
	CommunicationQueueMessage,
	WhatsAppContent,
} from "./types";
 
export type ConsultationConfirmationResult =
	| { status: "sent"; actualRecipient: string; provider: string }
	| {
			status: "failed";
			errorCode: AdapterErrorCode;
			errorMessage: string;
	  };
 
/**
 * Send a free-consultation booking confirmation via WhatsApp.
 *
 * Mirrors whatsapp-board-invite.ts / whatsapp-otp.ts: routes through
 * WhatsAppAdapter so non-prod safety guards (override number + dry-run in
 * local/dev) apply. Falls back to console logging when WHATSAPP_ACCESS_TOKEN
 * is unset so dev envs never fail on this flow.
 *
 * The template is expected to take 1 body param:
 *   {{1}} booking reference (e.g. CONS-A1B2C3D4)
 *
 * Best-effort by design — the caller (consultations.routes.ts) fires this
 * without blocking the booking response, so a delivery failure here never
 * fails the user-facing submission.
 */
export async function sendConsultationConfirmation(
	env: CloudflareBindings,
	params: {
		toPhone: string; // E.164, e.g. "+919876543210"
		reference: string;
	},
	dal?: Dal,
): Promise<ConsultationConfirmationResult> {
	const accessToken = env.WHATSAPP_ACCESS_TOKEN;
	const templateName =
		env.WHATSAPP_CONSULTATION_TEMPLATE ?? TPL_CONSULTATION_CONFIRMATION;
 
	if (!accessToken) {
		console.log(
			`[WhatsApp ConsultationConfirmation] Phone: ${params.toPhone} | Reference: ${params.reference} | Provider: console (no WhatsApp credentials)`,
		);
		return {
			status: "sent",
			actualRecipient: params.toPhone,
			provider: "console",
		};
	}
 
	const strippedPhone = params.toPhone.replace("+", "");
 
	const content: WhatsAppContent = {
		templateName,
		languageCode: "en",
		components: [
			{
				type: "body",
				parameters: [{ type: "text", text: params.reference }],
			},
		],
	};
 
	const message: CommunicationQueueMessage = {
		logId: 0, // Not queued — transactional direct send, no log entry yet
		channel: "whatsapp",
		recipient: strippedPhone,
		eventType: "consultation_confirmation",
		content,
		transactional: true,
	};
 
	const adapter = new WhatsAppAdapter();
	const result = await adapter.send(message, env, dal);
 
	if (result.status === "failed") {
		console.error(
			`[WhatsApp ConsultationConfirmation] Failed (${result.errorCode ?? "unknown"}): ${result.errorMessage}`,
		);
		return {
			status: "failed",
			errorCode: result.errorCode ?? "unknown",
			errorMessage: result.errorMessage ?? "Unknown delivery error",
		};
	}
 
	console.log(
		`[WhatsApp ConsultationConfirmation] Sent to ${result.actualRecipient} (provider: ${result.provider}${result.externalId ? `, message_id: ${result.externalId}` : ""})`,
	);
 
	return {
		status: "sent",
		actualRecipient: result.actualRecipient,
		provider: result.provider,
	};
}
 
// Convenience for the booking route — same waitUntil-safe pattern as
// fireInternalNotification (internal-notifications.ts): Hono throws when
// executionCtx is unset (e.g. tests that don't pass the 4th arg to
// app.request), so the try/catch fallback matters. sendConsultationConfirmation
// never throws, but the fallback branch still guards against an unexpected
// rejection turning into an unhandled promise rejection.
export function fireConsultationConfirmation(
	c: Context,
	dal: Dal,
	toPhone: string,
	reference: string,
): void {
	const promise = sendConsultationConfirmation(
		c.env as CloudflareBindings,
		{ toPhone, reference },
		dal,
	).catch((err) => {
		console.error("[WhatsApp ConsultationConfirmation] Unhandled error:", err);
	});
	try {
		c.executionCtx.waitUntil(promise);
	} catch {
		// Fire-and-forget outside a Workers request context (e.g. tests) — the
		// .catch above already prevents an unhandled rejection.
	}
}