feat(whatsapp): WhatsApp Business API notification channel (#640 part D)
Ports filpgame/picpeak's WhatsApp integration with substantial adaptation
to fit our codebase patterns. Deliver the gallery-ready notification via
Meta Graph API in addition to (or instead of) email — useful where the
customer base expects WhatsApp by default. Strictly opt-in behind the new
`whatsapp` feature flag.
### Backend
- **Migration 136** (`whatsapp_configs` + `whatsapp_queue`). Loose-FK on
`event_id` matching our `inbound_documents` / `expenses` pattern (NOT
filpgame's hard FK — deleting an event shouldn't RESTRICT on stale queue
rows). Composite index on `(status, retry_count, created_at)` covers the
poll path.
- **`whatsappService.js`**: thin Meta Graph client. Meta API version bumped
v19 → v20 (filpgame's v19 deprecates Q3 2026); configurable via
`WHATSAPP_META_API_VERSION` env var. Timeout dropped 10s → 8s for
processor budget. Errors surface the Meta `error.code` so the processor
can tell retryable from permanent.
- **`whatsappProcessor.js`**: queue processor polling every 30s (configurable
via `WHATSAPP_QUEUE_POLL_MS`), 10 messages per cycle, 3 retries before
marking `failed`. Default language sourced from
`app_settings.general_default_language` (matches our email-language
resolution pattern); replaces filpgame's hardcoded `pt_BR` fallback.
Falls back to `en_US` if nothing is configured. No-ops gracefully when
the `whatsapp` flag is off, the config row is missing, or the access
token isn't set.
- **`adminWhatsapp.js`**: three routes (GET/PUT config, POST test). Gated
by `requireFeatureFlag('whatsapp')` so operators who haven't enabled it
can't see the surface. Access token masked as `'********'` on GET;
masked values silently preserve the stored token on PUT. Enabling with
no Phone Number ID, template name, or token (and none stored) fails at
the validator.
- **Two hook points** in `adminEvents.js`:
- **Create-and-publish-in-one-step**: queues immediately after the
`gallery_created` email when `!isDraft && customerPhone &&
waConfig.enabled`. Password from `req.body` is still in scope.
- **Publish-from-draft** (`POST /:id/publish`): queues with the password
the admin re-typed via PR #627's `PublishGalleryDialog`. When no
password was typed (legacy API consumers without dialog), passes empty
string so the password line renders blank rather than leaking the
`(set at creation)` sentinel.
- **`server.js`**: starts `whatsappQueueProcessor` at boot. Non-fatal if it
fails to start (logged as warning).
- **`feature_flags`**: new `whatsapp` flag in `KNOWN_FLAGS` and
`DEFAULT_FLAGS` (default false).
### Frontend
- **`featureFlags.service.ts`**: `'whatsapp'` added to `FeatureKey` union.
- **`FeaturesTab.tsx`**: WhatsApp card in the Communication section
(between Incoming mail and Messaging). Smartphone icon, "new" status,
sidebar-hidden (no sidebar entry — config lives under Settings).
- **`whatsapp.service.ts`** (new): typed client for the three admin routes.
- **`WhatsAppTab.tsx`** (new): Settings tab. Form for Phone Number ID,
WABA ID, access token (masked toggle), template name, and enabled flag.
Separate card below for a static test send. Token masking matches the
server's `'********'` sentinel — admin can edit other fields without
re-entering the token.
- **`SettingsPage.tsx`**: WhatsApp tab nav item gated on `flags.whatsapp`
(so it shows only when the feature is enabled); render block wires
`<WhatsAppTab />`.
### i18n
22 new EN + 22 new DE entries covering the Settings tab form, the
Features-tab card, plus `admin.activities.whatsapp_config_updated` +
`admin.notificationMessages.whatsappConfigUpdated` for the bell /
dashboard surfaces from PR #637.
### Deliberately NOT included
- filpgame's **password-encryption-at-rest** layer
(`password_encrypted`/`password_iv`/`password_key_version` columns).
Our publish-from-draft password recovery uses the admin re-type flow
from #627 (PublishGalleryDialog) — no plaintext at rest.
### Setup notes for operators
1. Create a Meta Business Account + WhatsApp Business App.
2. Register a phone number and obtain `phone_number_id` + `waba_id`.
3. Create a system-user access token (long-lived recommended).
4. Submit a message template for approval. The default `gallery_ready`
expects 5 body parameters: customer name, event name, gallery link,
password line, expiry date.
5. Enable the `whatsapp` feature flag.
6. Enter credentials under Settings → WhatsApp, send a test, then enable
delivery.
### Test plan
- [x] Backend `node -c` on all new/changed files clean
- [x] `tsc --noEmit` on frontend clean
- [x] Backend dev container restart picks up new files, /health OK
- [ ] Manual: enable `whatsapp` flag → Settings → WhatsApp tab appears
- [ ] Manual: save config with masked-only token (existing token preserved)
- [ ] Manual: enable=true without phone_number_id rejected at PUT
- [ ] Manual: enable=true without stored or new token rejected at PUT
- [ ] Manual: create-and-publish event with customer_phone → queue row
inserts with message_type='gallery_created'
- [ ] Manual: publish-from-draft via PublishGalleryDialog with password →
queue row uses the admin-typed password in the {{4}} line
- [ ] Manual: test send to a real phone with valid Meta config + approved
template → Meta returns messages[0].id, toast shows the id
- [ ] Manual: bell renders "WhatsApp configuration updated" in DE when
the config_updated activity fires (via PR #637 smart default)
This commit is contained in:
@@ -836,6 +836,29 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
});
|
||||
}
|
||||
|
||||
// WhatsApp gallery_ready notification (#640D). Fires when the event is
|
||||
// created NOT as a draft, the `whatsapp` flag is on, a config exists, and
|
||||
// the customer supplied a phone number. Non-fatal: a queue failure should
|
||||
// never block gallery creation.
|
||||
if (!isDraft && customerPhone) {
|
||||
try {
|
||||
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
|
||||
const waConfig = await getWhatsAppConfig();
|
||||
if (waConfig && waConfig.enabled) {
|
||||
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
|
||||
customer_name: customerName || '',
|
||||
event_name,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : '',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null,
|
||||
language: null, // resolved by processor via general_default_language
|
||||
});
|
||||
}
|
||||
} catch (waError) {
|
||||
logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event.published when the event is created NOT as a draft. The
|
||||
// separate /publish endpoint fires it for the draft → live transition;
|
||||
// this covers the "create-and-publish in one shot" path.
|
||||
@@ -1142,6 +1165,34 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
|
||||
});
|
||||
}
|
||||
|
||||
// WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery
|
||||
// dialog (#627) hands us the password back so we can deliver it via
|
||||
// WhatsApp as well. Uses customer_phone from the persisted event row.
|
||||
if (event.customer_phone) {
|
||||
try {
|
||||
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
|
||||
const waConfig = await getWhatsAppConfig();
|
||||
if (waConfig && waConfig.enabled) {
|
||||
const { shareUrl: shareUrlForWa } = await buildShareLinkVariants({
|
||||
slug: event.slug, shareToken: event.share_token,
|
||||
});
|
||||
await queueWhatsapp(parseInt(id, 10), event.customer_phone, 'gallery_created', {
|
||||
customer_name: event.customer_name || event.host_name || '',
|
||||
event_name: event.event_name,
|
||||
gallery_link: shareUrlForWa || `${await getFrontendBaseUrl()}/gallery/${event.slug}`,
|
||||
// Plaintext only when the admin re-typed at publish; otherwise
|
||||
// omit so the buildComponents() helper renders an empty {{4}}
|
||||
// line instead of leaking the "(set at creation)" sentinel.
|
||||
gallery_password: requirePassword && password ? password : '',
|
||||
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
|
||||
language: null, // resolved by processor via general_default_language
|
||||
});
|
||||
}
|
||||
} catch (waError) {
|
||||
logger.warn('Failed to queue WhatsApp notification on publish', { error: waError.message });
|
||||
}
|
||||
}
|
||||
|
||||
await logActivity('event_published',
|
||||
{ event_name: event.event_name },
|
||||
id,
|
||||
|
||||
@@ -80,6 +80,10 @@ const KNOWN_FLAGS = [
|
||||
// the Project Overview cockpit ("book to project" hours control, 360°
|
||||
// rollup feed). Lights up the Clients section. Customers never see it.
|
||||
'projects',
|
||||
// WhatsApp Business API delivery channel (migration 136, #640D). Strictly
|
||||
// opt-in — operators must register a Meta-approved template before turning
|
||||
// it on. Independent of email; both can fire on the same event.
|
||||
'whatsapp',
|
||||
];
|
||||
|
||||
// Spec defaults for any flag missing from the DB (e.g. a row added by a
|
||||
@@ -107,6 +111,7 @@ const DEFAULT_FLAGS = {
|
||||
incomingInvoices: false,
|
||||
expenses: false,
|
||||
projects: false,
|
||||
whatsapp: false,
|
||||
};
|
||||
|
||||
async function readAllFlags() {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Admin WhatsApp configuration routes (#640 part D).
|
||||
*
|
||||
* GET /api/admin/whatsapp/config — returns config with access_token masked
|
||||
* PUT /api/admin/whatsapp/config — upsert; masked tokens preserved
|
||||
* POST /api/admin/whatsapp/test — send a static test message to verify the
|
||||
* Meta credentials + template approval
|
||||
*
|
||||
* Ported from filpgame/picpeak with a feature-flag gate via
|
||||
* `requireFeatureFlag('whatsapp')` and tighter validation on the enable path
|
||||
* (Phone Number ID, template name, AND access token all required to flip
|
||||
* `enabled=true`).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const { sendWhatsAppMessage } = require('../services/whatsappService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Gate everything behind the feature flag — operators who haven't enabled
|
||||
// WhatsApp shouldn't see the routes (matches the accounting / contracts
|
||||
// pattern). The Settings UI hides the tab as well; this is defence in depth.
|
||||
router.use(requireFeatureFlag('whatsapp'));
|
||||
|
||||
router.get('/config', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const config = await db('whatsapp_configs').first();
|
||||
if (!config) {
|
||||
return res.json({
|
||||
phone_number_id: '',
|
||||
waba_id: '',
|
||||
access_token: '',
|
||||
template_name: 'gallery_ready',
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
phone_number_id: config.phone_number_id,
|
||||
waba_id: config.waba_id,
|
||||
access_token: config.access_token ? '********' : '',
|
||||
template_name: config.template_name,
|
||||
enabled: Boolean(config.enabled),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('GET whatsapp-config error:', error);
|
||||
res.status(500).json({ error: 'Failed to load WhatsApp configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
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 existing = await db('whatsapp_configs').first();
|
||||
const isEnabled = Boolean(enabled);
|
||||
|
||||
const data = {
|
||||
phone_number_id: phone_number_id || '',
|
||||
waba_id: waba_id || '',
|
||||
template_name: template_name || 'gallery_ready',
|
||||
enabled: isEnabled,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
// Only persist the access_token when a real value (not the masked sentinel)
|
||||
// is supplied. This lets the admin PATCH everything else without re-entering
|
||||
// their long-lived Meta token every time.
|
||||
const hasNewToken = access_token && access_token !== '********';
|
||||
const hasStoredToken = existing && Boolean(existing.access_token);
|
||||
|
||||
if (hasNewToken) {
|
||||
data.access_token = access_token;
|
||||
} else if (!existing && !hasNewToken) {
|
||||
// First-time insert without a real token — reject so we never store an
|
||||
// unusable enabled=true config.
|
||||
return res.status(400).json({ error: 'Access token is required when saving a new configuration' });
|
||||
}
|
||||
|
||||
if (isEnabled) {
|
||||
if (!data.phone_number_id) {
|
||||
return res.status(400).json({ error: 'Phone Number ID is required to enable WhatsApp' });
|
||||
}
|
||||
if (!data.template_name) {
|
||||
return res.status(400).json({ error: 'Template name is required to enable WhatsApp' });
|
||||
}
|
||||
if (!hasNewToken && !hasStoredToken) {
|
||||
return res.status(400).json({ error: 'Access token is required to enable WhatsApp' });
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await db('whatsapp_configs').where('id', existing.id).update(data);
|
||||
} else {
|
||||
if (!data.access_token) data.access_token = '';
|
||||
await db('whatsapp_configs').insert(data);
|
||||
}
|
||||
|
||||
await logActivity(
|
||||
'whatsapp_config_updated',
|
||||
{ phone_number_id, enabled: isEnabled },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username },
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('PUT whatsapp-config error:', error);
|
||||
res.status(500).json({ error: 'Failed to save WhatsApp configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/test', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { phone } = req.body;
|
||||
if (!phone) {
|
||||
return res.status(400).json({ error: 'Phone number is required' });
|
||||
}
|
||||
|
||||
const config = await db('whatsapp_configs').first();
|
||||
if (!config || !config.phone_number_id || !config.access_token) {
|
||||
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',
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
const result = await sendWhatsAppMessage(phone, config, 'en_US', testComponents);
|
||||
res.json({ success: true, messageId: result.messageId });
|
||||
} catch (error) {
|
||||
logger.error('WhatsApp test send error:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to send test message' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,259 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* WhatsApp queue processor (#640 part D).
|
||||
*
|
||||
* Polls `whatsapp_queue` every 30s. For each pending row whose retry_count <
|
||||
* 3, builds the Meta template components from the stored message_data,
|
||||
* resolves the language code, and sends via whatsappService. Transient
|
||||
* failures bump retry_count; permanent failures mark the row 'failed'.
|
||||
*
|
||||
* Ported from filpgame/picpeak with the following changes:
|
||||
* - Default language sourced from `app_settings.general_default_language`
|
||||
* (matches our email-language resolution pattern) instead of a hardcoded
|
||||
* `pt_BR`. Falls back to `en` then `en_US` if nothing is configured.
|
||||
* - Cycle size + interval pulled from env vars so low-volume installs can
|
||||
* dial back the poll frequency.
|
||||
* - Exits gracefully when the `whatsapp` feature flag is off (no config
|
||||
* polling, no queue queries).
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { sendWhatsAppMessage } = require('./whatsappService');
|
||||
|
||||
// IETF language tag (with hyphen or underscore) → Meta template language code.
|
||||
// Meta template languages: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/supported-languages
|
||||
const LANGUAGE_MAP = {
|
||||
en: 'en_US', 'en-us': 'en_US', 'en_us': 'en_US',
|
||||
de: 'de_DE', 'de-de': 'de_DE', 'de_de': 'de_DE',
|
||||
pt: 'pt_BR', ptbr: 'pt_BR', 'pt-br': 'pt_BR', 'pt_br': 'pt_BR',
|
||||
ru: 'ru_RU', 'ru-ru': 'ru_RU', 'ru_ru': 'ru_RU',
|
||||
nl: 'nl_NL', 'nl-nl': 'nl_NL', 'nl_nl': 'nl_NL',
|
||||
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',
|
||||
};
|
||||
|
||||
// Per-locale label embedded in the {{4}} password line. Meta templates only
|
||||
// accept positional parameters in the body, so the "Password:" prefix has to
|
||||
// be baked into the parameter itself rather than living in the template body.
|
||||
const PASSWORD_LABELS = {
|
||||
pt_BR: '🔒 Senha',
|
||||
en_US: '🔒 Password',
|
||||
de_DE: '🔒 Passwort',
|
||||
ru_RU: '🔒 Пароль',
|
||||
nl_NL: '🔒 Wachtwoord',
|
||||
fr_FR: '🔒 Mot de passe',
|
||||
es_ES: '🔒 Contraseña',
|
||||
it_IT: '🔒 Password',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = parseInt(process.env.WHATSAPP_QUEUE_POLL_MS || '30000', 10);
|
||||
const CYCLE_BATCH_SIZE = parseInt(process.env.WHATSAPP_QUEUE_BATCH || '10', 10);
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
async function getSystemDefaultLanguageCode() {
|
||||
try {
|
||||
const row = await db('app_settings')
|
||||
.where('setting_key', 'general_default_language')
|
||||
.first();
|
||||
if (row && row.setting_value) {
|
||||
let lang = row.setting_value;
|
||||
try { lang = JSON.parse(lang); } catch (_) { /* not JSON, use raw */ }
|
||||
const resolved = resolveLanguageCode(typeof lang === 'string' ? lang.trim() : '');
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('whatsappProcessor: general_default_language read failed', { error: error.message });
|
||||
}
|
||||
return 'en_US';
|
||||
}
|
||||
|
||||
function formatDate(raw, metaLangCode) {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const intlLocale = INTL_LOCALE_MAP[metaLangCode] || 'en-US';
|
||||
return d.toLocaleDateString(intlLocale, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
function buildComponents(data, metaLang) {
|
||||
const label = PASSWORD_LABELS[metaLang] || PASSWORD_LABELS.en_US;
|
||||
const hasRealPassword = data.gallery_password
|
||||
&& data.gallery_password !== 'No password required'
|
||||
&& data.gallery_password !== '(set at creation)';
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
async function getWhatsAppConfig() {
|
||||
try {
|
||||
return await db('whatsapp_configs').first();
|
||||
} catch (error) {
|
||||
logger.debug('whatsappProcessor: failed to read whatsapp_configs', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a WhatsApp message. Safe to call without checking the feature flag
|
||||
* upstream — the processor's poll loop is the gate, so a queued message just
|
||||
* sits idle if the flag is off. Routes still SHOULD check the flag before
|
||||
* calling so the customer-facing error path (silent feature disabled vs. real
|
||||
* queue failure) stays distinguishable.
|
||||
*/
|
||||
async function queueWhatsapp(eventId, recipientPhone, messageType, messageData) {
|
||||
try {
|
||||
await db('whatsapp_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_phone: recipientPhone,
|
||||
message_type: messageType,
|
||||
message_data: JSON.stringify(messageData || {}),
|
||||
status: 'pending',
|
||||
retry_count: 0,
|
||||
created_at: new Date(),
|
||||
});
|
||||
logger.info(`WhatsApp queued: ${messageType} → ${recipientPhone}`);
|
||||
} catch (error) {
|
||||
logger.error('Error queueing WhatsApp message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll cycle. Reads up to CYCLE_BATCH_SIZE pending rows, sends each, and
|
||||
* updates retry/error/status fields. Wraps everything in defensive try/catch
|
||||
* so a single bad row can't stall the rest of the batch.
|
||||
*/
|
||||
async function processWhatsAppQueue() {
|
||||
let config;
|
||||
try {
|
||||
config = await getWhatsAppConfig();
|
||||
} catch (e) {
|
||||
// Tables not migrated yet — just bail.
|
||||
return;
|
||||
}
|
||||
if (!config || !config.enabled || !config.phone_number_id || !config.access_token) return;
|
||||
|
||||
const defaultLanguage = await getSystemDefaultLanguageCode();
|
||||
|
||||
let pending;
|
||||
try {
|
||||
pending = await db('whatsapp_queue')
|
||||
.where('status', 'pending')
|
||||
.andWhere('retry_count', '<', MAX_RETRIES)
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(CYCLE_BATCH_SIZE);
|
||||
} catch (error) {
|
||||
logger.error('WhatsApp queue: failed to query pending rows', { error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
logger.info(`WhatsApp queue: processing ${pending.length} message(s)`);
|
||||
|
||||
for (const item of pending) {
|
||||
try {
|
||||
const data = typeof item.message_data === 'string'
|
||||
? JSON.parse(item.message_data || '{}')
|
||||
: item.message_data || {};
|
||||
|
||||
const requestedLang = resolveLanguageCode(data.language);
|
||||
const metaLang = requestedLang || defaultLanguage;
|
||||
const components = buildComponents(data, metaLang);
|
||||
|
||||
await sendWhatsAppMessage(item.recipient_phone, config, metaLang, components);
|
||||
|
||||
await db('whatsapp_queue')
|
||||
.where('id', item.id)
|
||||
.update({ status: 'sent', sent_at: new Date(), error_message: null });
|
||||
} catch (error) {
|
||||
const newRetryCount = (item.retry_count || 0) + 1;
|
||||
const exhausted = newRetryCount >= MAX_RETRIES;
|
||||
await db('whatsapp_queue')
|
||||
.where('id', item.id)
|
||||
.update({
|
||||
retry_count: newRetryCount,
|
||||
error_message: String(error.message).slice(0, 2000),
|
||||
...(exhausted ? { status: 'failed' } : {}),
|
||||
});
|
||||
logger.error(
|
||||
`WhatsApp message ${item.id} ${exhausted ? 'failed (permanent)' : `retry ${newRetryCount}/${MAX_RETRIES}`}:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startWhatsAppQueueProcessor() {
|
||||
if (pollHandle) {
|
||||
logger.info('WhatsApp queue processor already running — skipping start');
|
||||
return;
|
||||
}
|
||||
// Fire once shortly after boot so the first message in a fresh install
|
||||
// doesn't wait the full poll interval.
|
||||
setTimeout(() => {
|
||||
processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue initial run failed', e));
|
||||
}, 5000);
|
||||
pollHandle = setInterval(() => {
|
||||
processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue cycle failed', e));
|
||||
}, POLL_INTERVAL_MS);
|
||||
logger.info(`WhatsApp queue processor started (poll every ${POLL_INTERVAL_MS}ms)`);
|
||||
}
|
||||
|
||||
function stopWhatsAppQueueProcessor() {
|
||||
if (pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
logger.info('WhatsApp queue processor stopped');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queueWhatsapp,
|
||||
processWhatsAppQueue,
|
||||
startWhatsAppQueueProcessor,
|
||||
stopWhatsAppQueueProcessor,
|
||||
getWhatsAppConfig,
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* WhatsApp Business API client (#640 part D).
|
||||
*
|
||||
* Thin wrapper over Meta Graph API for sending template messages. The
|
||||
* processor is responsible for queueing + retries; this module is just the
|
||||
* HTTP call. Ported from filpgame/picpeak with a few cleanups:
|
||||
* - Meta API version bumped to v20 (filpgame was on v19, deprecated in Q3 2026).
|
||||
* - Timeout dropped to 8s — Meta typically responds in <1s; 10s was too long
|
||||
* for the processor's per-message budget at 10/cycle.
|
||||
* - Error surfaces include the Meta `error.code` so the processor can decide
|
||||
* between retryable transients and permanent failures (template not
|
||||
* approved, recipient opted out, etc.).
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const META_API_VERSION = process.env.WHATSAPP_META_API_VERSION || 'v20.0';
|
||||
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`;
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
/**
|
||||
* Normalise a phone number into Meta's expected `+E164` form.
|
||||
* Strips non-digits, prepends `+`. Rejects clearly-invalid inputs early so
|
||||
* the processor can mark the row permanently failed without a network call.
|
||||
*/
|
||||
function normalizePhone(phone) {
|
||||
if (!phone) throw new Error('Invalid phone number: null or empty');
|
||||
const digits = String(phone).replace(/\D/g, '');
|
||||
if (digits.length < 10) {
|
||||
throw new Error(`Invalid phone number: too short after normalisation (${phone})`);
|
||||
}
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one WhatsApp template message. `components` is an array of strings
|
||||
* mapped into the template's positional {{1}}…{{N}} body parameters.
|
||||
*
|
||||
* Returns `{ messageId }` on success. Throws on any non-2xx — the processor
|
||||
* catches and decides retry vs. fail based on the error code surfaced in the
|
||||
* thrown message.
|
||||
*/
|
||||
async function sendWhatsAppMessage(recipientPhone, config, languageCode, components) {
|
||||
const normalised = normalizePhone(recipientPhone);
|
||||
|
||||
const payload = {
|
||||
messaging_product: 'whatsapp',
|
||||
to: normalised,
|
||||
type: 'template',
|
||||
template: {
|
||||
name: config.template_name,
|
||||
language: { code: languageCode },
|
||||
components: [
|
||||
{
|
||||
type: 'body',
|
||||
parameters: components.map((text) => ({ type: 'text', text: String(text || '') })),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${META_API_BASE}/${config.phone_number_id}/messages`,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
const messageId = response.data?.messages?.[0]?.id ?? 'unknown';
|
||||
logger.info(`WhatsApp message sent: ${messageId} → ${normalised}`);
|
||||
return { messageId };
|
||||
} catch (error) {
|
||||
const metaError = error.response?.data?.error;
|
||||
const metaCode = metaError?.code;
|
||||
const metaMessage = metaError?.message;
|
||||
const composed = metaCode
|
||||
? `${metaMessage || error.message} (code=${metaCode})`
|
||||
: (metaMessage || error.message);
|
||||
logger.error('WhatsApp API error', {
|
||||
error: composed, phone: normalised, code: metaCode,
|
||||
});
|
||||
throw new Error(composed);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { normalizePhone, sendWhatsAppMessage };
|
||||
Reference in New Issue
Block a user