feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)

Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.

This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.

## Schema (migration 138)

Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.

## Processor

- `parseTemplateParams(raw)` — defensive parser; falls back to the
  5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
  slots in the listed order, computed via a small switch on slot key.
  The password line still receives the locale-specific 🔒 label and
  the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
  the parsed array to `buildComponents` per message.

## Admin route

- GET surfaces `template_params` as the parsed array (default 5-slot
  when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
  before persisting, so the stored value is always the canonical
  sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
  admin's test message matches their configured slot shape — a
  reporter who configures 2 slots gets a 2-parameter test send, not
  the legacy 5-parameter payload.

## UI

- `WhatsAppTab` gets a checkbox + up/down list under the Template
  language field. Each slot shows its current `{{N}}` position when
  checked, an em-dash when unchecked. Live preview below the list:
  "Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
  human-readable names.

## Tests

- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
  covering: parseTemplateParams sanitization (unknown keys, duplicates,
  non-strings, malformed JSON, all-invalid fallback, pre-parsed array
  acceptance) and buildComponents shape (reporter's 2-slot case,
  reorder, empty list, locale-specific password label, password
  sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.

## Migration numbering

Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.

## Honest caveat

Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
This commit is contained in:
Paul Nothaft
2026-06-21 20:51:20 +02:00
parent 4fd7709596
commit 16055cdc41
8 changed files with 426 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,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');
});
};
+33 -12
View File
@@ -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) {
+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
* 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,
};
@@ -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<WhatsAppTemplateParam[]>(
[...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 = () => {
</p>
</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">
<input
type="checkbox"
+13
View File
@@ -1828,6 +1828,19 @@
"templateLanguage": "Vorlagensprache",
"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“.",
"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",
"savedToast": "WhatsApp-Einstellungen gespeichert.",
"testHeading": "Testnachricht senden",
+13
View File
@@ -1386,6 +1386,19 @@
"templateLanguage": "Template language",
"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.",
"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",
"savedToast": "WhatsApp settings saved.",
"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
* 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 {
phone_number_id: string;
waba_id: string;
@@ -16,6 +34,11 @@ export interface WhatsAppConfig {
// otherwise Meta returns template_not_found_in_language (132001). Empty
// string falls through to general_default_language.
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;
}