Merge pull request #650 from the-luap/fix/whatsapp-template-params-647-followup

feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
This commit is contained in:
Paul Nothaft
2026-06-21 21:35:09 +02:00
committed by GitHub
8 changed files with 428 additions and 29 deletions
@@ -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']);
});
});
@@ -0,0 +1,40 @@
/**
* Migration 140: 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`.
*
* Slot 140: lands after PR #649 (137 — whatsapp_template_language) and
* PR #646 (138 — slideshow_share, 139 — slideshow_styling). Additive +
* `hasColumn`-guarded so re-running on an already-migrated DB is a
* safe no-op.
*/
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');
});
};
+33 -12
View File
@@ -21,6 +21,11 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const { sendWhatsAppMessage } = require('../services/whatsappService'); const { sendWhatsAppMessage } = require('../services/whatsappService');
const {
buildComponents,
parseTemplateParams,
DEFAULT_TEMPLATE_PARAMS,
} = require('../services/whatsappProcessor');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
// Gate everything behind the feature flag — operators who haven't enabled // 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: '', access_token: '',
template_name: 'gallery_ready', template_name: 'gallery_ready',
template_language: '', template_language: '',
template_params: DEFAULT_TEMPLATE_PARAMS,
enabled: false, enabled: false,
}); });
} }
@@ -47,6 +53,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req,
access_token: config.access_token ? '********' : '', access_token: config.access_token ? '********' : '',
template_name: config.template_name, template_name: config.template_name,
template_language: config.template_language || '', template_language: config.template_language || '',
template_params: parseTemplateParams(config.template_params),
enabled: Boolean(config.enabled), enabled: Boolean(config.enabled),
}); });
} catch (error) { } 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) => { router.put('/config', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { 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 existing = await db('whatsapp_configs').first();
const isEnabled = Boolean(enabled); const isEnabled = Boolean(enabled);
@@ -71,11 +78,19 @@ router.put('/config', adminAuth, requirePermission('settings.edit'), async (req,
? template_language.trim().slice(0, 20) ? 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 = { const data = {
phone_number_id: phone_number_id || '', phone_number_id: phone_number_id || '',
waba_id: waba_id || '', waba_id: waba_id || '',
template_name: template_name || 'gallery_ready', template_name: template_name || 'gallery_ready',
template_language: normalizedTemplateLanguage, template_language: normalizedTemplateLanguage,
template_params: JSON.stringify(sanitizedTemplateParams),
enabled: isEnabled, enabled: isEnabled,
updated_at: new Date(), 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' }); 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 // Use the configured template language so non-English templates can be
// tested too (#647). Falls back to en_US for the default `gallery_ready` // tested too (#647). Falls back to en_US for the default `gallery_ready`
// shape that ships in English. // shape that ships in English.
const language = (config.template_language && config.template_language.trim()) || 'en_US'; 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); const result = await sendWhatsAppMessage(phone, config, language, testComponents);
res.json({ success: true, messageId: result.messageId }); res.json({ success: true, messageId: result.messageId });
} catch (error) { } catch (error) {
+59 -15
View File
@@ -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 * Build the positional body components for the configured template. The
* default `gallery_ready` template (operator-registered) expects: * `params` list controls which built-in values are emitted, and in what
* {{1}} customer_name * order — so an admin who registered a 2-parameter Meta template with
* {{2}} event_name * `{{1}} = event_name, {{2}} = gallery_link` (e.g. #647) configures
* {{3}} gallery_link * `template_params: ["event_name","gallery_link"]` and gets exactly those
* {{4}} password line (with localised "🔒 Password:" prefix, or empty) * two positional values per send.
* {{5}} expiry date (or empty) *
* 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 label = PASSWORD_LABELS[metaLang] || PASSWORD_LABELS.en_US;
const hasRealPassword = data.gallery_password const hasRealPassword = data.gallery_password
&& data.gallery_password !== 'No password required' && data.gallery_password !== 'No password required'
@@ -143,13 +177,18 @@ function buildComponents(data, metaLang) {
const passwordLine = hasRealPassword ? `${label}: ${data.gallery_password}` : ''; const passwordLine = hasRealPassword ? `${label}: ${data.gallery_password}` : '';
const expiryLine = formatDate(data.expiry_date, metaLang); const expiryLine = formatDate(data.expiry_date, metaLang);
return [ const valueFor = (key) => {
data.customer_name || '', switch (key) {
data.event_name || '', case 'customer_name': return data.customer_name || '';
data.gallery_link || '', case 'event_name': return data.event_name || '';
passwordLine, case 'gallery_link': return data.gallery_link || '';
expiryLine, case 'password_line': return passwordLine;
]; case 'expiry_date': return expiryLine;
default: return '';
}
};
return params.map(valueFor);
} }
async function getWhatsAppConfig() { async function getWhatsAppConfig() {
@@ -211,6 +250,7 @@ async function processWhatsAppQueue() {
// 3. en_US — hardcoded last resort. // 3. en_US — hardcoded last resort.
const configLanguage = resolveLanguageCode(config.template_language); const configLanguage = resolveLanguageCode(config.template_language);
const defaultLanguage = configLanguage || await getSystemDefaultLanguageCode(); const defaultLanguage = configLanguage || await getSystemDefaultLanguageCode();
const params = parseTemplateParams(config.template_params);
let pending; let pending;
try { try {
@@ -236,7 +276,7 @@ async function processWhatsAppQueue() {
const requestedLang = resolveLanguageCode(data.language); const requestedLang = resolveLanguageCode(data.language);
const metaLang = requestedLang || defaultLanguage; const metaLang = requestedLang || defaultLanguage;
const components = buildComponents(data, metaLang); const components = buildComponents(data, metaLang, params);
await sendWhatsAppMessage(item.recipient_phone, config, metaLang, components); await sendWhatsAppMessage(item.recipient_phone, config, metaLang, components);
@@ -291,4 +331,8 @@ module.exports = {
startWhatsAppQueueProcessor, startWhatsAppQueueProcessor,
stopWhatsAppQueueProcessor, stopWhatsAppQueueProcessor,
getWhatsAppConfig, getWhatsAppConfig,
// Exported for the admin route's test send + unit tests (#647 follow-up).
buildComponents,
parseTemplateParams,
DEFAULT_TEMPLATE_PARAMS,
}; };
@@ -2,9 +2,13 @@ import React, { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; 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 { 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). * WhatsApp Business API configuration tab (#640D).
@@ -32,6 +36,9 @@ export const WhatsAppTab: React.FC = () => {
const [accessToken, setAccessToken] = useState(''); const [accessToken, setAccessToken] = useState('');
const [templateName, setTemplateName] = useState('gallery_ready'); const [templateName, setTemplateName] = useState('gallery_ready');
const [templateLanguage, setTemplateLanguage] = useState(''); const [templateLanguage, setTemplateLanguage] = useState('');
const [templateParams, setTemplateParams] = useState<WhatsAppTemplateParam[]>(
[...WHATSAPP_TEMPLATE_PARAMS],
);
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [showToken, setShowToken] = useState(false); const [showToken, setShowToken] = useState(false);
const [testPhone, setTestPhone] = useState(''); const [testPhone, setTestPhone] = useState('');
@@ -45,10 +52,36 @@ export const WhatsAppTab: React.FC = () => {
setAccessToken(data.access_token || ''); setAccessToken(data.access_token || '');
setTemplateName(data.template_name || 'gallery_ready'); setTemplateName(data.template_name || 'gallery_ready');
setTemplateLanguage(data.template_language || ''); 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)); setEnabled(Boolean(data.enabled));
} }
}, [data]); }, [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({ const save = useMutation({
mutationFn: () => whatsappService.updateConfig({ mutationFn: () => whatsappService.updateConfig({
phone_number_id: phoneNumberId, phone_number_id: phoneNumberId,
@@ -56,6 +89,7 @@ export const WhatsAppTab: React.FC = () => {
access_token: accessToken, access_token: accessToken,
template_name: templateName, template_name: templateName,
template_language: templateLanguage, template_language: templateLanguage,
template_params: templateParams,
enabled, enabled,
}), }),
onSuccess: () => { onSuccess: () => {
@@ -195,6 +229,81 @@ export const WhatsAppTab: React.FC = () => {
</p> </p>
</div> </div>
{/* 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. */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.whatsapp.templateParams', 'Template parameters')}
</label>
<p className="mb-2 text-xs text-neutral-500 dark:text-neutral-400">
{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.',
)}
</p>
<ul className="rounded-lg border border-neutral-200 dark:border-neutral-700 divide-y divide-neutral-200 dark:divide-neutral-700">
{WHATSAPP_TEMPLATE_PARAMS.map((slot) => {
const idx = templateParams.indexOf(slot);
const included = idx >= 0;
return (
<li
key={slot}
className="flex items-center gap-3 p-3 bg-white dark:bg-neutral-900"
>
<input
type="checkbox"
checked={included}
onChange={() => toggleParam(slot)}
className="rounded border-neutral-300"
aria-label={t(`settings.whatsapp.params.${slot}`, slot) as string}
/>
<span className="flex-1 text-sm text-neutral-800 dark:text-neutral-200">
<span className="font-mono text-xs text-neutral-500 dark:text-neutral-400 mr-2">
{included ? `{{${idx + 1}}}` : '—'}
</span>
{t(`settings.whatsapp.params.${slot}`, slot)}
</span>
{included && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveParam(idx, -1)}
disabled={idx === 0}
className="p-1 disabled:opacity-30"
aria-label={t('settings.whatsapp.paramMoveUp', 'Move up') as string}
>
<ChevronUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => moveParam(idx, 1)}
disabled={idx === templateParams.length - 1}
className="p-1 disabled:opacity-30"
aria-label={t('settings.whatsapp.paramMoveDown', 'Move down') as string}
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
)}
</li>
);
})}
</ul>
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
{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(', '),
})}
</p>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200"> <label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
<input <input
type="checkbox" type="checkbox"
+13
View File
@@ -1838,6 +1838,19 @@
"templateLanguage": "Vorlagensprache", "templateLanguage": "Vorlagensprache",
"templateLanguagePlaceholder": "z. B. en_US, de_DE, ar, pt_BR", "templateLanguagePlaceholder": "z. B. en_US, de_DE, ar, pt_BR",
"templateLanguageHint": "Meta-Sprachcode der Vorlage, exakt wie im Meta Business Manager hinterlegt (`ar`, `en_US`, `de_DE`, `pt_BR` usw.). Leer lassen, um auf die Standardsprache der Installation zurückzufallen. Stimmt der Code nicht mit einer registrierten Vorlage überein, meldet Meta „Vorlage in dieser Sprache nicht gefunden“.", "templateLanguageHint": "Meta-Sprachcode der Vorlage, exakt wie im Meta Business Manager hinterlegt (`ar`, `en_US`, `de_DE`, `pt_BR` usw.). Leer lassen, um auf die Standardsprache der Installation zurückzufallen. Stimmt der Code nicht mit einer registrierten Vorlage überein, meldet Meta „Vorlage in dieser Sprache nicht gefunden“.",
"templateParams": "Vorlagenparameter",
"templateParamsHint": "Auswählen, welche Werte als Positionsparameter (Slot 1, Slot 2, …) an Meta gesendet werden, und in der Reihenfolge anordnen, in der sie im Vorlagentext stehen. Nicht markierte Slots werden gar nicht gesendet. Standard entspricht der eingebauten `gallery_ready`-Vorlage mit 5 Parametern.",
"templateParamsEmpty": "Keine Slots ausgewählt das Speichern fällt auf die Standardform mit 5 Parametern zurück.",
"templateParamsPreview": "Ihre Vorlage erhält: {{preview}}",
"paramMoveUp": "Nach oben",
"paramMoveDown": "Nach unten",
"params": {
"customer_name": "Kundenname",
"event_name": "Event-Name",
"gallery_link": "Galerie-Link",
"password_line": "Passwort-Zeile (mit 🔒-Präfix, leer ohne Passwort)",
"expiry_date": "Ablaufdatum"
},
"enabled": "WhatsApp-Benachrichtigungen senden", "enabled": "WhatsApp-Benachrichtigungen senden",
"savedToast": "WhatsApp-Einstellungen gespeichert.", "savedToast": "WhatsApp-Einstellungen gespeichert.",
"testHeading": "Testnachricht senden", "testHeading": "Testnachricht senden",
+13
View File
@@ -1394,6 +1394,19 @@
"templateLanguage": "Template language", "templateLanguage": "Template language",
"templateLanguagePlaceholder": "e.g. en_US, de_DE, ar, pt_BR", "templateLanguagePlaceholder": "e.g. en_US, de_DE, ar, pt_BR",
"templateLanguageHint": "Meta template language code, exactly as you registered it in Meta Business Manager (`ar`, `en_US`, `de_DE`, `pt_BR`, etc.). Leave empty to fall back to the system default language. Meta returns \"template not found in language\" if this doesn't match a registered template.", "templateLanguageHint": "Meta template language code, exactly as you registered it in Meta Business Manager (`ar`, `en_US`, `de_DE`, `pt_BR`, etc.). Leave empty to fall back to the system default language. Meta returns \"template not found in language\" if this doesn't match a registered template.",
"templateParams": "Template parameters",
"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.",
"templateParamsEmpty": "No slots selected — saving will fall back to the default 5-parameter shape.",
"templateParamsPreview": "Your template will receive: {{preview}}",
"paramMoveUp": "Move up",
"paramMoveDown": "Move down",
"params": {
"customer_name": "Customer name",
"event_name": "Event name",
"gallery_link": "Gallery link",
"password_line": "Password line (🔒-prefixed, empty when no password)",
"expiry_date": "Expiry date"
},
"enabled": "Send WhatsApp notifications", "enabled": "Send WhatsApp notifications",
"savedToast": "WhatsApp settings saved.", "savedToast": "WhatsApp settings saved.",
"testHeading": "Send a test message", "testHeading": "Send a test message",
+23
View File
@@ -6,6 +6,24 @@ import { api } from '../config/api';
* string when none is. The PUT silently preserves the stored token if the * string when none is. The PUT silently preserves the stored token if the
* masked sentinel is sent back unchanged. * masked sentinel is sent back unchanged.
*/ */
// Slot keys that map to the built-in `message_data` fields the queue
// processor knows how to substitute. Order = positional `{{N}}` order in the
// Meta-registered template body. Any other string is dropped server-side.
export type WhatsAppTemplateParam =
| 'customer_name'
| 'event_name'
| 'gallery_link'
| 'password_line'
| 'expiry_date';
export const WHATSAPP_TEMPLATE_PARAMS: WhatsAppTemplateParam[] = [
'customer_name',
'event_name',
'gallery_link',
'password_line',
'expiry_date',
];
export interface WhatsAppConfig { export interface WhatsAppConfig {
phone_number_id: string; phone_number_id: string;
waba_id: string; waba_id: string;
@@ -16,6 +34,11 @@ export interface WhatsAppConfig {
// otherwise Meta returns template_not_found_in_language (132001). Empty // otherwise Meta returns template_not_found_in_language (132001). Empty
// string falls through to general_default_language. // string falls through to general_default_language.
template_language: string; template_language: string;
// Ordered slot list controlling which built-in values are sent as
// positional `{{N}}` parameters to Meta, and in what order (#647
// follow-up). Empty (server-side) falls back to the legacy 5-slot
// gallery_ready shape so existing installs keep working unchanged.
template_params: WhatsAppTemplateParam[];
enabled: boolean; enabled: boolean;
} }