Files
picpeak/backend/src/services/whatsappService.js
T
Paul Nothaft 78c8e9d9f9 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)
2026-06-18 22:57:22 +02:00

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 };