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
+ {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(', '), + })} +
+