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)
95 lines
3.2 KiB
JavaScript
95 lines
3.2 KiB
JavaScript
'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 };
|