All files / src/lib/whatsapp template.ts

100% Statements 93/93
100% Branches 58/58
100% Functions 18/18
100% Lines 83/83

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                                                                                            235x 234x 234x 234x   1x                     353x   353x 152x 152x 146x   145x 145x   49x   71x   21x   49x 70x             353x                                       6x 6x     5x 5x 7x 7x 7x     5x   5x 7x       6x     5x                       24x 32x 32x               318x                 117x 117x                 115x   72x     115x                 115x 115x                 15x 15x                 15x 15x 15x                 25x 25x     25x 2x 23x 3x           25x 2x   23x 1x           23x 23x 2x       23x 2x           23x 23x 19x 24x 5x 19x   19x 2x     19x 5x 2x     2x         19x 2x               25x 3x 1x 2x 1x             25x 1x         25x    
/**
 * One module for WhatsApp message templates: parsing what Meta stores, and
 * validating what an admin types.
 *
 * WHY ONE FILE
 * This is `components/whatsapp/template-utils.ts` (163 lines, the read half:
 * parse the `components` JSON, find `{{N}}` slots, build a send payload,
 * render a preview) merged with `components/whatsapp/template-form.helpers.ts`
 * (106 lines, the write half: the category and language lists, the length
 * caps, `validateTemplate`). They were two halves of one subject, imported by
 * the same four screens, and neither had a colocated test.
 *
 * WHY NOT `packages/utils`
 * The shared-utilities bar is two or more apps (`docs/rules/shared-utilities.md`).
 * Only the portal composes a WhatsApp template — the marketplace and `go` have
 * no template surface at all, and `apps/api` has its own `lib/whatsapp/`. One
 * consumer is not a shared utility; it is a portal module that happens to be
 * imported four times. It lives under `lib/whatsapp/` next to the inbox's
 * triage helpers so the whole subject is in one directory.
 */
 
// ─── Reading a template ──────────────────────────────────────────────────────
//
// Meta stores a template's structure as a JSON string in the `components`
// column. These helpers parse that JSON, extract {{N}} variable slots, build
// the WhatsApp Cloud API `components` payload, and render preview text.
 
export type TemplateComponent = {
	type: string; // "HEADER" | "BODY" | "FOOTER" | "BUTTONS"
	format?: string; // "TEXT" | "IMAGE" | etc.
	text?: string;
	example?: { body_text?: string[][]; header_text?: string[] };
};
 
export type ParameterSlot = {
	componentType: "HEADER" | "BODY";
	index: number; // 1-based (matches {{1}}, {{2}}, etc.)
};
 
/**
 * Safely parse the JSON `components` string stored on a template.
 * Returns an empty array on null/invalid input.
 */
export function parseTemplateComponents(
	componentsJson: string | null,
): TemplateComponent[] {
	if (!componentsJson) return [];
	try {
		const parsed = JSON.parse(componentsJson);
		return Array.isArray(parsed) ? parsed : [];
	} catch {
		return [];
	}
}
 
/**
 * Scan parsed components for `{{N}}` placeholders and return a flat list
 * of slots grouped by component type (HEADER first, then BODY).
 */
export function extractParameterSlots(
	components: TemplateComponent[],
): ParameterSlot[] {
	const slots: ParameterSlot[] = [];
 
	for (const comp of components) {
		const type = comp.type?.toUpperCase();
		if (type !== "HEADER" && type !== "BODY") continue;
		if (!comp.text) continue;
 
		const matches = comp.text.match(/\{\{(\d+)\}\}/g);
		if (!matches) continue;
 
		const indices = [
			...new Set(
				matches.map((m) => Number.parseInt(m.replace(/\{|\}/g, ""), 10)),
			),
		].sort((a, b) => a - b);
 
		for (const idx of indices) {
			slots.push({
				componentType: type as "HEADER" | "BODY",
				index: idx,
			});
		}
	}
 
	return slots;
}
 
/**
 * Build the WhatsApp Cloud API `components` array from slot values.
 *
 * Example output:
 * ```json
 * [
 *   { "type": "body", "parameters": [
 *       { "type": "text", "text": "John" },
 *       { "type": "text", "text": "kitchen remodel" }
 *   ]}
 * ]
 * ```
 */
export function buildSendComponents(
	templateComponents: TemplateComponent[],
	paramValues: Record<string, string>,
): unknown[] {
	const slots = extractParameterSlots(templateComponents);
	if (slots.length === 0) return [];
 
	// Group slots by component type
	const grouped: Record<string, ParameterSlot[]> = {};
	for (const slot of slots) {
		const key = slot.componentType.toLowerCase();
		if (!grouped[key]) grouped[key] = [];
		grouped[key].push(slot);
	}
 
	const result: unknown[] = [];
 
	for (const [type, typeSlots] of Object.entries(grouped)) {
		const parameters = typeSlots.map((slot) => ({
			type: "text" as const,
			text: paramValues[`${slot.componentType}_${slot.index}`] || "",
		}));
		result.push({ type, parameters });
	}
 
	return result;
}
 
/**
 * Replace `{{N}}` placeholders in text with actual values for preview.
 * `values` is a map keyed like "BODY_1", "BODY_2", "HEADER_1", etc.
 */
export function renderPreviewText(
	text: string,
	componentType: "HEADER" | "BODY",
	values: Record<string, string>,
): string {
	return text.replace(/\{\{(\d+)\}\}/g, (match, num) => {
		const key = `${componentType}_${num}`;
		return values[key] || match;
	});
}
 
/**
 * Check whether a template has an approved status (case-insensitive).
 */
export function isApprovedTemplate(template: { status: string }): boolean {
	return template.status === "APPROVED" || template.status === "approved";
}
 
/**
 * Get the body text from parsed template components.
 */
export function getTemplateBodyText(
	components: TemplateComponent[],
): string | null {
	const body = components.find((c) => c.type?.toUpperCase() === "BODY");
	return body?.text ?? null;
}
 
/**
 * Get the header text from parsed template components (text headers only).
 */
export function getTemplateHeaderText(
	components: TemplateComponent[],
): string | null {
	const header = components.find(
		(c) =>
			c.type?.toUpperCase() === "HEADER" &&
			c.format?.toUpperCase() === "TEXT",
	);
	return header?.text ?? null;
}
 
/**
 * Get the footer text from parsed template components.
 */
export function getTemplateFooterText(
	components: TemplateComponent[],
): string | null {
	const footer = components.find((c) => c.type?.toUpperCase() === "FOOTER");
	return footer?.text ?? null;
}
 
// ─── Composing a template ────────────────────────────────────────────────────
//
// What the create form offers and what it refuses. The caps and the variable
// rules are Meta's, restated here so the admin hears about a rejection before
// the template is submitted rather than hours later in a webhook.
 
export const CATEGORIES = ["MARKETING", "UTILITY", "AUTHENTICATION"];
export const LANGUAGES = [
	{ value: "en", label: "English" },
	{ value: "hi", label: "Hindi" },
	{ value: "ta", label: "Tamil" },
	{ value: "te", label: "Telugu" },
	{ value: "kn", label: "Kannada" },
	{ value: "mr", label: "Marathi" },
];
 
export const MAX_BODY_LENGTH = 1024;
export const MAX_HEADER_LENGTH = 60;
export const MAX_FOOTER_LENGTH = 60;
 
export function validateTemplate(params: {
	name: string;
	bodyText: string;
	headerType: string;
	headerText: string;
	footerText: string;
}): string[] {
	const errors: string[] = [];
	const { name, bodyText, headerType, headerText, footerText } = params;
 
	// Name validation
	if (name.length < 1) {
		errors.push("Template name is required.");
	} else if (!/^[a-z][a-z0-9_]*$/.test(name)) {
		errors.push(
			"Name must start with a letter and contain only lowercase letters, numbers, and underscores.",
		);
	}
 
	// Body validation
	if (!bodyText.trim()) {
		errors.push("Body text is required.");
	} else {
		if (bodyText.length > MAX_BODY_LENGTH) {
			errors.push(
				`Body text exceeds ${MAX_BODY_LENGTH} characters (currently ${bodyText.length}).`,
			);
		}
 
		// Variables can't be at the start or end
		const trimmed = bodyText.trim();
		if (/^\{\{\d+\}\}/.test(trimmed)) {
			errors.push(
				"Body text cannot start with a variable. Add text before {{1}}.",
			);
		}
		if (/\{\{\d+\}\}$/.test(trimmed)) {
			errors.push(
				"Body text cannot end with a variable. Add text after the last variable.",
			);
		}
 
		// Variables must be sequential starting from {{1}}
		const varMatches = bodyText.match(/\{\{(\d+)\}\}/g);
		if (varMatches) {
			const varNums = varMatches
				.map((v) => Number.parseInt(v.replace(/\{|\}/g, ""), 10))
				.sort((a, b) => a - b);
			const unique = [...new Set(varNums)];
 
			if (unique[0] !== 1) {
				errors.push("Variables must start from {{1}}.");
			}
 
			for (let i = 1; i < unique.length; i++) {
				if (unique[i] !== unique[i - 1] + 1) {
					errors.push(
						`Variables must be sequential. Missing {{${unique[i - 1] + 1}}}.`,
					);
					break;
				}
			}
 
			// No consecutive variables without text between them
			if (/\{\{\d+\}\}\s*\{\{\d+\}\}/.test(bodyText)) {
				errors.push(
					"Add text between consecutive variables (e.g., {{1}} and {{2}}).",
				);
			}
		}
	}
 
	// Header validation
	if (headerType === "text") {
		if (!headerText.trim()) {
			errors.push("Header text is required when header type is Text.");
		} else if (headerText.length > MAX_HEADER_LENGTH) {
			errors.push(
				`Header text exceeds ${MAX_HEADER_LENGTH} characters (currently ${headerText.length}).`,
			);
		}
	}
 
	// Footer validation
	if (footerText.length > MAX_FOOTER_LENGTH) {
		errors.push(
			`Footer text exceeds ${MAX_FOOTER_LENGTH} characters (currently ${footerText.length}).`,
		);
	}
 
	return errors;
}