From 4fd7709596e7a0dd3fedef63772ddd52ce5561c9 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 20 Jun 2026 23:29:08 +0200 Subject: [PATCH] fix(whatsapp): admin-pinned template language + Arabic locale support (#647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../137_add_whatsapp_template_language.js | 31 ++++++++++++++ backend/src/routes/adminWhatsapp.js | 20 ++++++++- backend/src/services/whatsappProcessor.js | 41 +++++++++++++++++-- .../features/settings/tabs/WhatsAppTab.tsx | 20 +++++++++ frontend/src/i18n/locales/de.json | 3 ++ frontend/src/i18n/locales/en.json | 3 ++ frontend/src/services/whatsapp.service.ts | 5 +++ 7 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/core/137_add_whatsapp_template_language.js diff --git a/backend/migrations/core/137_add_whatsapp_template_language.js b/backend/migrations/core/137_add_whatsapp_template_language.js new file mode 100644 index 00000000..f7f8c764 --- /dev/null +++ b/backend/migrations/core/137_add_whatsapp_template_language.js @@ -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'); + }); +}; diff --git a/backend/src/routes/adminWhatsapp.js b/backend/src/routes/adminWhatsapp.js index 411dbd3f..b21d5a4c 100644 --- a/backend/src/routes/adminWhatsapp.js +++ b/backend/src/routes/adminWhatsapp.js @@ -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); diff --git a/backend/src/services/whatsappProcessor.js b/backend/src/services/whatsappProcessor.js index b2796273..6f40b4c0 100644 --- a/backend/src/services/whatsappProcessor.js +++ b/backend/src/services/whatsappProcessor.js @@ -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 { diff --git a/frontend/src/features/settings/tabs/WhatsAppTab.tsx b/frontend/src/features/settings/tabs/WhatsAppTab.tsx index 97f17ad5..0420e96b 100644 --- a/frontend/src/features/settings/tabs/WhatsAppTab.tsx +++ b/frontend/src/features/settings/tabs/WhatsAppTab.tsx @@ -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 = () => {

+
+ + setTemplateLanguage(e.target.value)} + placeholder={t('settings.whatsapp.templateLanguagePlaceholder', 'e.g. en_US, de_DE, ar, pt_BR') as string} + /> +

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

+
+