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
@@ -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;
}