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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user