diff --git a/backend/__tests__/utils/whatsappBuildComponents.test.js b/backend/__tests__/utils/whatsappBuildComponents.test.js new file mode 100644 index 00000000..1001bc30 --- /dev/null +++ b/backend/__tests__/utils/whatsappBuildComponents.test.js @@ -0,0 +1,136 @@ +/** + * Unit tests for the WhatsApp template-parameter selection (#647 follow-up). + * + * Pins: + * - parseTemplateParams sanitizes unknown / non-string / duplicate keys, + * and falls back to the default 5-slot shape on empty / malformed input. + * - buildComponents emits ONLY the listed slots, in the listed order, so + * a 2-parameter template (event_name + gallery_link) sends exactly 2 + * positional values — the reporter's exact case from issue #647. + * - The legacy 5-slot default still works unchanged for installs that + * haven't reconfigured. + */ +const { + buildComponents, + parseTemplateParams, + DEFAULT_TEMPLATE_PARAMS, +} = require('../../src/services/whatsappProcessor'); + +const baseData = { + customer_name: 'Aisha', + event_name: 'Wedding 2026', + gallery_link: 'https://picpeak.example/wedding-2026', + gallery_password: 'StrongPass!', + expiry_date: '2026-12-31T00:00:00Z', +}; + +describe('parseTemplateParams', () => { + test('returns the default 5-slot shape for empty / null / undefined input', () => { + expect(parseTemplateParams('')).toEqual(DEFAULT_TEMPLATE_PARAMS); + expect(parseTemplateParams(null)).toEqual(DEFAULT_TEMPLATE_PARAMS); + expect(parseTemplateParams(undefined)).toEqual(DEFAULT_TEMPLATE_PARAMS); + }); + + test('returns the default shape for malformed JSON', () => { + expect(parseTemplateParams('{not json')).toEqual(DEFAULT_TEMPLATE_PARAMS); + }); + + test('returns the default shape when JSON parses to a non-array', () => { + expect(parseTemplateParams('"event_name"')).toEqual(DEFAULT_TEMPLATE_PARAMS); + expect(parseTemplateParams('{"a":1}')).toEqual(DEFAULT_TEMPLATE_PARAMS); + }); + + test('preserves the reporter\'s 2-slot shape', () => { + const out = parseTemplateParams(JSON.stringify(['event_name', 'gallery_link'])); + expect(out).toEqual(['event_name', 'gallery_link']); + }); + + test('drops unknown slot keys', () => { + const out = parseTemplateParams(JSON.stringify([ + 'event_name', 'unknown_slot', 'gallery_link', '__proto__', + ])); + expect(out).toEqual(['event_name', 'gallery_link']); + }); + + test('drops duplicate slot keys (first wins)', () => { + const out = parseTemplateParams(JSON.stringify([ + 'event_name', 'gallery_link', 'event_name', + ])); + expect(out).toEqual(['event_name', 'gallery_link']); + }); + + test('drops non-string entries', () => { + const out = parseTemplateParams(JSON.stringify([ + 'event_name', 42, null, { a: 1 }, 'gallery_link', + ])); + expect(out).toEqual(['event_name', 'gallery_link']); + }); + + test('falls back to default when every entry is invalid', () => { + const out = parseTemplateParams(JSON.stringify([ + 'unknown_a', 'unknown_b', null, 7, + ])); + expect(out).toEqual(DEFAULT_TEMPLATE_PARAMS); + }); + + test('also accepts an already-parsed array (defensive)', () => { + const out = parseTemplateParams(['event_name', 'gallery_link']); + expect(out).toEqual(['event_name', 'gallery_link']); + }); +}); + +describe('buildComponents', () => { + test('legacy default shape emits 5 positional values, gallery_ready order', () => { + const out = buildComponents(baseData, 'en_US'); + expect(out).toHaveLength(5); + expect(out[0]).toBe('Aisha'); + expect(out[1]).toBe('Wedding 2026'); + expect(out[2]).toBe('https://picpeak.example/wedding-2026'); + expect(out[3]).toBe('🔒 Password: StrongPass!'); + // expiry date is locale-formatted but always non-empty for a valid date + expect(out[4]).toMatch(/\d{2}/); + }); + + test('reporter\'s 2-slot shape — event_name + gallery_link, in that order', () => { + const out = buildComponents(baseData, 'ar', ['event_name', 'gallery_link']); + expect(out).toEqual(['Wedding 2026', 'https://picpeak.example/wedding-2026']); + }); + + test('reorder: gallery_link first, event_name second', () => { + const out = buildComponents(baseData, 'en_US', ['gallery_link', 'event_name']); + expect(out).toEqual(['https://picpeak.example/wedding-2026', 'Wedding 2026']); + }); + + test('empty slot list emits an empty components array (admin opted into nothing)', () => { + const out = buildComponents(baseData, 'en_US', []); + expect(out).toEqual([]); + }); + + test('password_line uses the locale-specific label when included', () => { + const out = buildComponents(baseData, 'ar', ['password_line']); + expect(out).toEqual(['🔒 كلمة المرور: StrongPass!']); + }); + + test('password_line is empty when no real password is set', () => { + const out = buildComponents( + { ...baseData, gallery_password: '' }, + 'en_US', + ['password_line'], + ); + expect(out).toEqual(['']); + }); + + test('password_line is empty for the "No password required" sentinel', () => { + const out = buildComponents( + { ...baseData, gallery_password: 'No password required' }, + 'en_US', + ['password_line'], + ); + expect(out).toEqual(['']); + }); + + test('omits expiry_date when omitted from the slot list', () => { + const out = buildComponents(baseData, 'en_US', ['event_name']); + expect(out).toEqual(['Wedding 2026']); + }); +}); diff --git a/backend/migrations/core/138_add_whatsapp_template_params.js b/backend/migrations/core/138_add_whatsapp_template_params.js new file mode 100644 index 00000000..cd4098a9 --- /dev/null +++ b/backend/migrations/core/138_add_whatsapp_template_params.js @@ -0,0 +1,38 @@ +/** + * Migration 138: WhatsApp template parameter selection (#647 follow-up). + * + * Adds a `template_params` column to `whatsapp_configs` that stores an + * ordered JSON array of slot keys naming which built-in values are sent + * as positional parameters to the configured Meta template (and in what + * order). Reporter @Rekoo-PS hit the gap that motivated this: their + * template body uses only `{{1}} = event_name` + `{{2}} = gallery_link`, + * but the hardcoded `buildComponents` shape always emitted 5 parameters + * matching `gallery_ready` — so Meta rejected with a parameter-count + * mismatch even after the language fix landed (migration 137). + * + * Schema: TEXT column, empty/null means "fall back to the legacy 5-slot + * shape" so installs that haven't reconfigured continue to work without + * intervention. The processor's `buildComponents` reads this column, + * parses the array, and emits only the listed slots in the listed order. + * + * Known slot keys (any other keys are ignored): `customer_name`, + * `event_name`, `gallery_link`, `password_line`, `expiry_date`. + * + * Additive + `hasColumn`-guarded. The settling number depends on the + * merge order with PR #646; flagged for renumber if that lands first. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('whatsapp_configs'))) return; + if (await knex.schema.hasColumn('whatsapp_configs', 'template_params')) return; + await knex.schema.alterTable('whatsapp_configs', (table) => { + table.text('template_params').notNullable().defaultTo(''); + }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('whatsapp_configs'))) return; + if (!(await knex.schema.hasColumn('whatsapp_configs', 'template_params'))) return; + await knex.schema.alterTable('whatsapp_configs', (table) => { + table.dropColumn('template_params'); + }); +}; diff --git a/backend/src/routes/adminWhatsapp.js b/backend/src/routes/adminWhatsapp.js index b21d5a4c..7fa3bd22 100644 --- a/backend/src/routes/adminWhatsapp.js +++ b/backend/src/routes/adminWhatsapp.js @@ -21,6 +21,11 @@ const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); const { sendWhatsAppMessage } = require('../services/whatsappService'); +const { + buildComponents, + parseTemplateParams, + DEFAULT_TEMPLATE_PARAMS, +} = require('../services/whatsappProcessor'); const logger = require('../utils/logger'); // Gate everything behind the feature flag — operators who haven't enabled @@ -38,6 +43,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req, access_token: '', template_name: 'gallery_ready', template_language: '', + template_params: DEFAULT_TEMPLATE_PARAMS, enabled: false, }); } @@ -47,6 +53,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req, access_token: config.access_token ? '********' : '', template_name: config.template_name, template_language: config.template_language || '', + template_params: parseTemplateParams(config.template_params), enabled: Boolean(config.enabled), }); } catch (error) { @@ -57,7 +64,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req, router.put('/config', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { - const { phone_number_id, waba_id, access_token, template_name, template_language, enabled } = req.body; + const { phone_number_id, waba_id, access_token, template_name, template_language, template_params, enabled } = req.body; const existing = await db('whatsapp_configs').first(); const isEnabled = Boolean(enabled); @@ -71,11 +78,19 @@ router.put('/config', adminAuth, requirePermission('settings.edit'), async (req, ? template_language.trim().slice(0, 20) : ''; + // Template parameter selection (#647 follow-up). Round-trip through the + // processor's sanitizer so unknown / duplicate / non-string keys are + // dropped before persistence, and we always store the canonical JSON + // array shape. Empty input falls back to the legacy 5-slot default so + // existing installs keep working. + const sanitizedTemplateParams = parseTemplateParams(template_params); + const data = { phone_number_id: phone_number_id || '', waba_id: waba_id || '', template_name: template_name || 'gallery_ready', template_language: normalizedTemplateLanguage, + template_params: JSON.stringify(sanitizedTemplateParams), enabled: isEnabled, updated_at: new Date(), }; @@ -139,21 +154,27 @@ router.post('/test', adminAuth, requirePermission('settings.edit'), async (req, return res.status(400).json({ error: 'WhatsApp is not configured' }); } - // Static template parameters — the admin only needs to confirm that the - // configured Meta credentials + approved template can deliver to a real - // phone, not the per-event substitution logic. - const testComponents = [ - 'PicPeak Test', - 'Test Gallery', - 'https://example.com/gallery/test', - '', - '', - ]; - // Use the configured template language so non-English templates can be // tested too (#647). Falls back to en_US for the default `gallery_ready` // shape that ships in English. const language = (config.template_language && config.template_language.trim()) || 'en_US'; + + // Build the test components through the SAME path the production queue + // uses, so the test message matches the admin's `template_params` shape + // (#647 follow-up). With a 2-slot template (event_name + gallery_link) + // we send exactly 2 positional values; with the default 5-slot shape + // we send the legacy "PicPeak Test" payload. Static placeholder data — + // the admin only needs to confirm credentials + template approval, not + // the per-event substitution logic. + const params = parseTemplateParams(config.template_params); + const testData = { + customer_name: 'PicPeak Test', + event_name: 'Test Gallery', + gallery_link: 'https://example.com/gallery/test', + gallery_password: '', + expiry_date: null, + }; + const testComponents = buildComponents(testData, language, params); const result = await sendWhatsAppMessage(phone, config, language, testComponents); res.json({ success: true, messageId: result.messageId }); } catch (error) { diff --git a/backend/src/services/whatsappProcessor.js b/backend/src/services/whatsappProcessor.js index 6f40b4c0..6a34c5be 100644 --- a/backend/src/services/whatsappProcessor.js +++ b/backend/src/services/whatsappProcessor.js @@ -126,16 +126,50 @@ function formatDate(raw, metaLangCode) { } } +// Default slot order — matches the operator-registered `gallery_ready` +// template's 5-parameter shape and preserves pre-#647 behaviour for installs +// that haven't configured `template_params`. +const DEFAULT_TEMPLATE_PARAMS = ['customer_name', 'event_name', 'gallery_link', 'password_line', 'expiry_date']; +const KNOWN_TEMPLATE_PARAMS = new Set(DEFAULT_TEMPLATE_PARAMS); + +/** + * Parse `whatsapp_configs.template_params` (JSON array string) into a + * sanitized slot list. Unknown / non-string / duplicated keys are dropped; + * empty / malformed input falls back to the default 5-slot shape so the + * legacy `gallery_ready` template keeps working. + */ +function parseTemplateParams(raw) { + if (!raw) return DEFAULT_TEMPLATE_PARAMS; + try { + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!Array.isArray(parsed)) return DEFAULT_TEMPLATE_PARAMS; + const seen = new Set(); + const out = []; + for (const k of parsed) { + if (typeof k !== 'string') continue; + if (!KNOWN_TEMPLATE_PARAMS.has(k)) continue; + if (seen.has(k)) continue; + seen.add(k); + out.push(k); + } + return out.length > 0 ? out : DEFAULT_TEMPLATE_PARAMS; + } catch (_) { + return DEFAULT_TEMPLATE_PARAMS; + } +} + /** * Build the positional body components for the configured template. The - * default `gallery_ready` template (operator-registered) expects: - * {{1}} customer_name - * {{2}} event_name - * {{3}} gallery_link - * {{4}} password line (with localised "🔒 Password:" prefix, or empty) - * {{5}} expiry date (or empty) + * `params` list controls which built-in values are emitted, and in what + * order — so an admin who registered a 2-parameter Meta template with + * `{{1}} = event_name, {{2}} = gallery_link` (e.g. #647) configures + * `template_params: ["event_name","gallery_link"]` and gets exactly those + * two positional values per send. + * + * Known slot keys: customer_name, event_name, gallery_link, password_line, + * expiry_date. Anything else is filtered out by parseTemplateParams. */ -function buildComponents(data, metaLang) { +function buildComponents(data, metaLang, params = DEFAULT_TEMPLATE_PARAMS) { const label = PASSWORD_LABELS[metaLang] || PASSWORD_LABELS.en_US; const hasRealPassword = data.gallery_password && data.gallery_password !== 'No password required' @@ -143,13 +177,18 @@ function buildComponents(data, metaLang) { const passwordLine = hasRealPassword ? `${label}: ${data.gallery_password}` : ''; const expiryLine = formatDate(data.expiry_date, metaLang); - return [ - data.customer_name || '', - data.event_name || '', - data.gallery_link || '', - passwordLine, - expiryLine, - ]; + const valueFor = (key) => { + switch (key) { + case 'customer_name': return data.customer_name || ''; + case 'event_name': return data.event_name || ''; + case 'gallery_link': return data.gallery_link || ''; + case 'password_line': return passwordLine; + case 'expiry_date': return expiryLine; + default: return ''; + } + }; + + return params.map(valueFor); } async function getWhatsAppConfig() { @@ -211,6 +250,7 @@ async function processWhatsAppQueue() { // 3. en_US — hardcoded last resort. const configLanguage = resolveLanguageCode(config.template_language); const defaultLanguage = configLanguage || await getSystemDefaultLanguageCode(); + const params = parseTemplateParams(config.template_params); let pending; try { @@ -236,7 +276,7 @@ async function processWhatsAppQueue() { const requestedLang = resolveLanguageCode(data.language); const metaLang = requestedLang || defaultLanguage; - const components = buildComponents(data, metaLang); + const components = buildComponents(data, metaLang, params); await sendWhatsAppMessage(item.recipient_phone, config, metaLang, components); @@ -291,4 +331,8 @@ module.exports = { startWhatsAppQueueProcessor, stopWhatsAppQueueProcessor, getWhatsAppConfig, + // Exported for the admin route's test send + unit tests (#647 follow-up). + buildComponents, + parseTemplateParams, + DEFAULT_TEMPLATE_PARAMS, }; diff --git a/frontend/src/features/settings/tabs/WhatsAppTab.tsx b/frontend/src/features/settings/tabs/WhatsAppTab.tsx index 0420e96b..dbb9ae98 100644 --- a/frontend/src/features/settings/tabs/WhatsAppTab.tsx +++ b/frontend/src/features/settings/tabs/WhatsAppTab.tsx @@ -2,9 +2,13 @@ import React, { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Save, Send, Eye, EyeOff } from 'lucide-react'; +import { Save, Send, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react'; import { Button, Card, CardContent, Input, Loading } from '../../../components/common'; -import { whatsappService } from '../../../services/whatsapp.service'; +import { + whatsappService, + WHATSAPP_TEMPLATE_PARAMS, + type WhatsAppTemplateParam, +} from '../../../services/whatsapp.service'; /** * WhatsApp Business API configuration tab (#640D). @@ -32,6 +36,9 @@ export const WhatsAppTab: React.FC = () => { const [accessToken, setAccessToken] = useState(''); const [templateName, setTemplateName] = useState('gallery_ready'); const [templateLanguage, setTemplateLanguage] = useState(''); + const [templateParams, setTemplateParams] = useState( + [...WHATSAPP_TEMPLATE_PARAMS], + ); const [enabled, setEnabled] = useState(false); const [showToken, setShowToken] = useState(false); const [testPhone, setTestPhone] = useState(''); @@ -45,10 +52,36 @@ export const WhatsAppTab: React.FC = () => { setAccessToken(data.access_token || ''); setTemplateName(data.template_name || 'gallery_ready'); setTemplateLanguage(data.template_language || ''); + // The server always returns a non-empty sanitized array (default 5-slot + // shape when the column is empty), so we can take it directly. + setTemplateParams( + data.template_params && data.template_params.length > 0 + ? data.template_params + : [...WHATSAPP_TEMPLATE_PARAMS], + ); setEnabled(Boolean(data.enabled)); } }, [data]); + // Toggle inclusion of a slot. When checked we append at the end (highest + // {{N}}); when unchecked we drop it from the list. Reordering uses the + // up/down buttons below. + const toggleParam = (key: WhatsAppTemplateParam) => { + setTemplateParams((prev) => + prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key], + ); + }; + + const moveParam = (idx: number, delta: -1 | 1) => { + setTemplateParams((prev) => { + const target = idx + delta; + if (target < 0 || target >= prev.length) return prev; + const next = [...prev]; + [next[idx], next[target]] = [next[target], next[idx]]; + return next; + }); + }; + const save = useMutation({ mutationFn: () => whatsappService.updateConfig({ phone_number_id: phoneNumberId, @@ -56,6 +89,7 @@ export const WhatsAppTab: React.FC = () => { access_token: accessToken, template_name: templateName, template_language: templateLanguage, + template_params: templateParams, enabled, }), onSuccess: () => { @@ -195,6 +229,81 @@ export const WhatsAppTab: React.FC = () => {

+ {/* Template parameter selection (#647 follow-up). Reporter's + template uses only event_name + gallery_link, but the legacy + shape hardcoded a 5-parameter `gallery_ready` payload that Meta + rejected with a parameter-count mismatch. This control lets the + admin pick which slots to send and in what positional order. */} +
+ +

+ {t( + 'settings.whatsapp.templateParamsHint', + 'Pick which built-in values are sent as positional template parameters (slot 1, slot 2, …), and arrange them so they match the order in your Meta-registered template body. Unchecked slots are not sent at all. Default matches the built-in `gallery_ready` 5-parameter shape.', + )} +

+ +

+ {templateParams.length === 0 + ? t( + 'settings.whatsapp.templateParamsEmpty', + 'No slots selected — saving will fall back to the default 5-parameter shape.', + ) + : t('settings.whatsapp.templateParamsPreview', 'Your template will receive: {{preview}}', { + preview: templateParams.map((slot, i) => `{{${i + 1}}} = ${slot}`).join(', '), + })} +

+
+