fix(whatsapp): admin-pinned template language + Arabic locale support (#647)
Reporter @Rekoo-PS hit three independent gaps trying to deliver an Arabic Meta template. Bundled here because they fan out from the same root cause (no first-class language config on the WhatsApp tab) and the review surfaces are tightly coupled. **1. Test send hardcoded `en_US` (`adminWhatsapp.js:141`).** Smoking gun for "I can't make it work" — Meta returned template_not_found_in_language (132001) on every test send for non-English templates, no matter what else the admin configured. Replaced with `config.template_language || 'en_US'`. **2. No `template_language` field on `whatsapp_configs`.** The only priors were per-message `data.language` (always null from our callers in `adminEvents.js:854,1188`) and `app_settings.general_default_language` (the *system UI* language, not the *template's* language registered with Meta). Migration 137 adds the column; GET + PUT surface it; the processor uses it as the highest-priority default when message_data doesn't override. Resolution order in `whatsappProcessor.processWhatsAppQueue` is now: 1. message_data.language (per-event override — caller path TBD) 2. config.template_language (admin-pinned template language) 3. app_settings.general_default_language (system fallback) 4. en_US (hardcoded last resort) **3. `LANGUAGE_MAP` + `PASSWORD_LABELS` didn't cover Arabic.** Added `ar` (Meta's single-code form per RFC; no region variant). For any language we don't enumerate (e.g. Turkish `tr_TR`, Chinese `zh_CN`, Hebrew `he_IL`), `resolveLanguageCode` now pass-throughs valid-shape codes (lowercase-language + optional underscore + uppercase-region) and forwards them to Meta as-is. If they don't match a registered template Meta returns 132001, which the test route already surfaces back to the admin via `error.message` — fail-loud, no silent fallback. Validation: - Unit smoke on `resolveLanguageCode` across 18 representative inputs (in-map, pass-through, canonicalization, rejection) — all behaviours correct. - Lint clean on all 7 changed files. - Frontend `tsc --noEmit` clean. - Migration `node -c` syntax-checked; additive + `hasColumn`-guarded so re-running is safe. Frontend: free-text input on the WhatsApp tab with EN + DE i18n. Pointing at Meta's supported-languages docs via the hint text — Meta's list grows; a hardcoded dropdown would rot. Closes #647.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Migration 137: WhatsApp template language (#647).
|
||||
*
|
||||
* Adds a `template_language` column to `whatsapp_configs` so admins can
|
||||
* pin their Meta-approved template's language code (e.g. `ar`, `en_US`,
|
||||
* `de_DE`) directly in Settings → WhatsApp. Without this column the only
|
||||
* resolution paths were per-message `data.language` (always null in our
|
||||
* own callers) and `app_settings.general_default_language` — both of
|
||||
* which are tied to the *system* UI language, not the *template's* language
|
||||
* registered with Meta. Reporter @Rekoo-PS hit this with an Arabic
|
||||
* template against the test-send route.
|
||||
*
|
||||
* Additive + hasColumn-guarded. Empty string default means "fall through
|
||||
* to general_default_language" — preserves current behaviour for installs
|
||||
* that don't set it.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('whatsapp_configs'))) return;
|
||||
if (await knex.schema.hasColumn('whatsapp_configs', 'template_language')) return;
|
||||
await knex.schema.alterTable('whatsapp_configs', (table) => {
|
||||
table.string('template_language', 20).notNullable().defaultTo('');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('whatsapp_configs'))) return;
|
||||
if (!(await knex.schema.hasColumn('whatsapp_configs', 'template_language'))) return;
|
||||
await knex.schema.alterTable('whatsapp_configs', (table) => {
|
||||
table.dropColumn('template_language');
|
||||
});
|
||||
};
|
||||
@@ -37,6 +37,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req,
|
||||
waba_id: '',
|
||||
access_token: '',
|
||||
template_name: 'gallery_ready',
|
||||
template_language: '',
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
@@ -45,6 +46,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req,
|
||||
waba_id: config.waba_id,
|
||||
access_token: config.access_token ? '********' : '',
|
||||
template_name: config.template_name,
|
||||
template_language: config.template_language || '',
|
||||
enabled: Boolean(config.enabled),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -55,15 +57,25 @@ 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, enabled } = req.body;
|
||||
const { phone_number_id, waba_id, access_token, template_name, template_language, enabled } = req.body;
|
||||
|
||||
const existing = await db('whatsapp_configs').first();
|
||||
const isEnabled = Boolean(enabled);
|
||||
|
||||
// Meta template-language codes are BCP-47 style: `ar`, `en_US`, `de_DE`,
|
||||
// `pt_BR`, etc. We accept arbitrary strings and pass through to Meta —
|
||||
// they'll return template_not_found_in_language (132001) if the code
|
||||
// doesn't match a registered template. No client-side allowlist because
|
||||
// Meta's supported-languages list changes.
|
||||
const normalizedTemplateLanguage = typeof template_language === 'string'
|
||||
? template_language.trim().slice(0, 20)
|
||||
: '';
|
||||
|
||||
const data = {
|
||||
phone_number_id: phone_number_id || '',
|
||||
waba_id: waba_id || '',
|
||||
template_name: template_name || 'gallery_ready',
|
||||
template_language: normalizedTemplateLanguage,
|
||||
enabled: isEnabled,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
@@ -138,7 +150,11 @@ router.post('/test', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
'',
|
||||
];
|
||||
|
||||
const result = await sendWhatsAppMessage(phone, config, 'en_US', testComponents);
|
||||
// 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';
|
||||
const result = await sendWhatsAppMessage(phone, config, language, testComponents);
|
||||
res.json({ success: true, messageId: result.messageId });
|
||||
} catch (error) {
|
||||
logger.error('WhatsApp test send error:', error);
|
||||
|
||||
@@ -33,6 +33,10 @@ const LANGUAGE_MAP = {
|
||||
fr: 'fr_FR', 'fr-fr': 'fr_FR', 'fr_fr': 'fr_FR',
|
||||
es: 'es_ES', 'es-es': 'es_ES', 'es_es': 'es_ES',
|
||||
it: 'it_IT', 'it-it': 'it_IT', 'it_it': 'it_IT',
|
||||
// Meta lists Arabic as a single code `ar` (no region variant). The common
|
||||
// BCP-47 region variants land on the same Meta code (#647).
|
||||
ar: 'ar', 'ar-sa': 'ar', 'ar_sa': 'ar', 'ar-eg': 'ar', 'ar_eg': 'ar',
|
||||
'ar-ar': 'ar', 'ar_ar': 'ar',
|
||||
};
|
||||
|
||||
// Per-locale label embedded in the {{4}} password line. Meta templates only
|
||||
@@ -47,12 +51,16 @@ const PASSWORD_LABELS = {
|
||||
fr_FR: '🔒 Mot de passe',
|
||||
es_ES: '🔒 Contraseña',
|
||||
it_IT: '🔒 Password',
|
||||
ar: '🔒 كلمة المرور',
|
||||
};
|
||||
|
||||
const INTL_LOCALE_MAP = {
|
||||
pt_BR: 'pt-BR', en_US: 'en-US', de_DE: 'de-DE',
|
||||
ru_RU: 'ru-RU', nl_NL: 'nl-NL', fr_FR: 'fr-FR',
|
||||
es_ES: 'es-ES', it_IT: 'it-IT',
|
||||
// Pick a representative region for Arabic date formatting. Meta has no
|
||||
// region variant on the template code, but JS Intl needs one.
|
||||
ar: 'ar-SA',
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = parseInt(process.env.WHATSAPP_QUEUE_POLL_MS || '30000', 10);
|
||||
@@ -64,11 +72,29 @@ let pollHandle = null;
|
||||
/**
|
||||
* Resolve a Meta template language code from whatever's in the message_data
|
||||
* (admin-set per-event language) or the system default.
|
||||
*
|
||||
* Pass-through fallback (#647): if the admin types a code we don't have in
|
||||
* the map (e.g. `tr_TR` for Turkish, `zh_CN` for Chinese), but it matches
|
||||
* the Meta BCP-47 shape, we trust them and forward it as-is. Meta returns
|
||||
* template_not_found_in_language (132001) if the code doesn't match a
|
||||
* registered template, surfacing the typo back to the admin via the test
|
||||
* route's error response.
|
||||
*/
|
||||
function resolveLanguageCode(lang) {
|
||||
if (!lang) return null; // signal: caller should fall through to default
|
||||
const normalised = String(lang).toLowerCase().replace(/-/g, '_');
|
||||
return LANGUAGE_MAP[normalised] || null;
|
||||
const raw = String(lang).trim();
|
||||
if (!raw) return null;
|
||||
const normalised = raw.toLowerCase().replace(/-/g, '_');
|
||||
if (LANGUAGE_MAP[normalised]) return LANGUAGE_MAP[normalised];
|
||||
// Pass-through for valid-shape Meta codes the map doesn't enumerate.
|
||||
// Accept `xx` or `xx_YY` (case-insensitive on input); emit Meta's
|
||||
// canonical lowercase-language + uppercase-region form.
|
||||
const passthrough = /^([a-z]{2})(?:[_-]([a-z]{2}))?$/i.exec(raw);
|
||||
if (passthrough) {
|
||||
const [, langPart, regionPart] = passthrough;
|
||||
return regionPart ? `${langPart.toLowerCase()}_${regionPart.toUpperCase()}` : langPart.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSystemDefaultLanguageCode() {
|
||||
@@ -175,7 +201,16 @@ async function processWhatsAppQueue() {
|
||||
}
|
||||
if (!config || !config.enabled || !config.phone_number_id || !config.access_token) return;
|
||||
|
||||
const defaultLanguage = await getSystemDefaultLanguageCode();
|
||||
// Priority order for the *default* (when message_data.language is null):
|
||||
// 1. config.template_language — admin-pinned to match their Meta-registered
|
||||
// template (#647). This is the only way to send Arabic/Chinese/etc.
|
||||
// templates correctly, since general_default_language is the UI
|
||||
// language not the template's.
|
||||
// 2. general_default_language — system fallback for installs that haven't
|
||||
// pinned a template_language.
|
||||
// 3. en_US — hardcoded last resort.
|
||||
const configLanguage = resolveLanguageCode(config.template_language);
|
||||
const defaultLanguage = configLanguage || await getSystemDefaultLanguageCode();
|
||||
|
||||
let pending;
|
||||
try {
|
||||
|
||||
@@ -31,6 +31,7 @@ export const WhatsAppTab: React.FC = () => {
|
||||
const [wabaId, setWabaId] = useState('');
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [templateName, setTemplateName] = useState('gallery_ready');
|
||||
const [templateLanguage, setTemplateLanguage] = useState('');
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [testPhone, setTestPhone] = useState('');
|
||||
@@ -43,6 +44,7 @@ export const WhatsAppTab: React.FC = () => {
|
||||
// Leave it visible-as-masked so the admin sees that a token exists.
|
||||
setAccessToken(data.access_token || '');
|
||||
setTemplateName(data.template_name || 'gallery_ready');
|
||||
setTemplateLanguage(data.template_language || '');
|
||||
setEnabled(Boolean(data.enabled));
|
||||
}
|
||||
}, [data]);
|
||||
@@ -53,6 +55,7 @@ export const WhatsAppTab: React.FC = () => {
|
||||
waba_id: wabaId,
|
||||
access_token: accessToken,
|
||||
template_name: templateName,
|
||||
template_language: templateLanguage,
|
||||
enabled,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -175,6 +178,23 @@ export const WhatsAppTab: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.whatsapp.templateLanguage', 'Template language')}
|
||||
</label>
|
||||
<Input
|
||||
value={templateLanguage}
|
||||
onChange={(e) => setTemplateLanguage(e.target.value)}
|
||||
placeholder={t('settings.whatsapp.templateLanguagePlaceholder', 'e.g. en_US, de_DE, ar, pt_BR') as string}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'settings.whatsapp.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.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -1825,6 +1825,9 @@
|
||||
"accessTokenHint": "Beim Abruf maskiert als \"********\" gespeichert. Maskierten Wert beibehalten, um das bestehende Token zu behalten; neuen Wert eingeben, um zu ersetzen.",
|
||||
"templateName": "Vorlagenname",
|
||||
"templateNameHint": "Name der von Meta genehmigten Nachrichtenvorlage. Die Standardvorlage `gallery_ready` erwartet 5 Body-Parameter: Kundenname, Event-Name, Galerie-Link, Passwortzeile, Ablaufdatum. Die Vorlage vor der Aktivierung im Meta Business Manager genehmigen lassen.",
|
||||
"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“.",
|
||||
"enabled": "WhatsApp-Benachrichtigungen senden",
|
||||
"savedToast": "WhatsApp-Einstellungen gespeichert.",
|
||||
"testHeading": "Testnachricht senden",
|
||||
|
||||
@@ -1383,6 +1383,9 @@
|
||||
"accessTokenHint": "Stored masked as \"********\" on GET. Leave the masked value to keep the existing token; type a new one to replace it.",
|
||||
"templateName": "Template name",
|
||||
"templateNameHint": "Name of the Meta-approved message template. The default `gallery_ready` expects 5 body parameters: customer name, event name, gallery link, password line, expiry date. Approve the template in Meta Business Manager before enabling.",
|
||||
"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.",
|
||||
"enabled": "Send WhatsApp notifications",
|
||||
"savedToast": "WhatsApp settings saved.",
|
||||
"testHeading": "Send a test message",
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface WhatsAppConfig {
|
||||
waba_id: string;
|
||||
access_token: string; // masked '********' on GET when a real token is stored
|
||||
template_name: string;
|
||||
// Meta template language code (`ar`, `en_US`, `de_DE`, etc.) (#647). Must
|
||||
// match the language the operator registered with Meta for `template_name`,
|
||||
// otherwise Meta returns template_not_found_in_language (132001). Empty
|
||||
// string falls through to general_default_language.
|
||||
template_language: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user