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:
Paul Nothaft
2026-06-18 22:57:22 +02:00
parent a3fcb5bc9e
commit 78c8e9d9f9
15 changed files with 995 additions and 8 deletions
@@ -0,0 +1,67 @@
/**
* Migration 136: WhatsApp Business API notification channel (#640 part D).
*
* Adds an alternative to the email channel for the gallery-created
* notification — useful in markets where customers expect WhatsApp by default
* (DACH photographers report this frequently). Strictly opt-in via the
* `whatsapp` feature flag; defaults OFF on every install.
*
* Two tables:
* - whatsapp_configs : single-row config (Meta phone_number_id, waba_id,
* access_token, template_name). Token is admin-only,
* masked on GET, never returned in plaintext outside
* the route layer.
* - whatsapp_queue : per-message queue mirroring email_queue's shape —
* recipient, message_type, message_data JSON, retry
* count, error_message. Polled by the WhatsApp queue
* processor every 30s.
*
* Loose-FK on event_id by design — matches `inbound_documents.event_id` and
* `expenses.event_id` and avoids the RESTRICT-on-delete problem (deleting an
* event shouldn't fail because a stale queue row references it).
*
* Ported from filpgame's #1 with adjustments: loose-FK, renumbered to next
* free migration slot, schema otherwise compatible.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('whatsapp_configs'))) {
await knex.schema.createTable('whatsapp_configs', (table) => {
table.increments('id').primary();
table.string('phone_number_id', 255).notNullable().defaultTo('');
table.string('waba_id', 255).notNullable().defaultTo('');
// Meta access tokens are long-lived JWT-style strings; 1000 chars
// covers system-user tokens with comfortable headroom.
table.string('access_token', 1000).notNullable().defaultTo('');
table.string('template_name', 255).notNullable().defaultTo('gallery_ready');
table.boolean('enabled').notNullable().defaultTo(false);
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
}
if (!(await knex.schema.hasTable('whatsapp_queue'))) {
await knex.schema.createTable('whatsapp_queue', (table) => {
table.increments('id').primary();
// Loose-FK: event_id references events.id but no FK constraint, so an
// event delete doesn't RESTRICT against stale queue rows.
table.integer('event_id').unsigned();
table.string('recipient_phone', 50).notNullable();
table.string('message_type', 50).notNullable();
table.json('message_data');
table.string('status', 20).notNullable().defaultTo('pending');
table.integer('retry_count').notNullable().defaultTo(0);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('scheduled_at').defaultTo(knex.fn.now());
table.timestamp('sent_at');
table.text('error_message');
// Index the poll path: pending + retry_count < threshold, ordered by
// created_at. Single composite index covers all three.
table.index(['status', 'retry_count', 'created_at'], 'whatsapp_queue_poll_index');
table.index(['event_id']);
});
}
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('whatsapp_queue');
await knex.schema.dropTableIfExists('whatsapp_configs');
};
+10
View File
@@ -639,6 +639,7 @@ app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags'));
app.use('/api/admin/whatsapp', require('./src/routes/adminWhatsapp'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
@@ -842,6 +843,15 @@ async function startServer() {
}
startEmailQueueProcessor();
// Start WhatsApp queue processor — no-ops each cycle unless the
// `whatsapp` flag is on and a config exists (migration 136, #640D).
try {
const { startWhatsAppQueueProcessor } = require('./src/services/whatsappProcessor');
startWhatsAppQueueProcessor();
} catch (err) {
logger.warn('WhatsApp queue processor start failed:', err.message);
}
// Start incoming-mail (IMAP) poller — no-ops each minute unless the
// `incomingMail` flag is on and a mailbox is configured (migration 128).
try {
+51
View File
@@ -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,
+5
View File
@@ -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() {
+149
View File
@@ -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;
+259
View File
@@ -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,
};
+94
View File
@@ -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 };
+1
View File
@@ -19,3 +19,4 @@ export { ThumbnailsTab } from './tabs/ThumbnailsTab';
export { ApiTokensTab } from './tabs/ApiTokensTab';
export { WebhooksTab } from './tabs/WebhooksTab';
export { AccountingTab } from './tabs/AccountingTab';
export { WhatsAppTab } from './tabs/WhatsAppTab';
@@ -6,6 +6,7 @@ import {
Images,
BellRing,
MessageSquare,
Smartphone,
Mailbox,
CalendarDays,
FileSignature,
@@ -179,6 +180,21 @@ export const FeaturesTab: React.FC = () => {
onToggle={(next) => setFlag('incomingMail', next)}
/>
<FeatureCard
icon={Smartphone}
title={t('settings.features.whatsapp.title', 'WhatsApp')}
description={t(
'settings.features.whatsapp.description',
'Deliver the gallery-ready notification via WhatsApp Business API in addition to email. Requires a Meta Business Account, an approved message template, and a customer phone number on the event. Configure credentials under Settings → WhatsApp.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarHidden
sidebarHiddenLabel={sidebarHiddenLabel}
enabled={staged.whatsapp}
onToggle={(next) => setFlag('whatsapp', next)}
/>
<FeatureCard
icon={MessageSquare}
title={t('settings.features.messaging.title', 'Messaging')}
@@ -0,0 +1,235 @@
import React, { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, Send, Eye, EyeOff } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
import { whatsappService } from '../../../services/whatsapp.service';
/**
* WhatsApp Business API configuration tab (#640D).
*
* Stores the Meta phone_number_id + waba_id + access_token + approved
* template_name. Access token is masked on GET (server returns '********');
* the PUT silently preserves the stored token when the user doesn't supply
* a fresh one — they can edit other fields without re-entering it. Enabling
* with no token (and none stored) fails at the route validator.
*
* The Test action fires a static template message at a phone the admin
* provides — useful to verify the credentials + template approval state
* without waiting for a real event-published trigger.
*/
export const WhatsAppTab: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['whatsapp-config'],
queryFn: () => whatsappService.getConfig(),
});
const [phoneNumberId, setPhoneNumberId] = useState('');
const [wabaId, setWabaId] = useState('');
const [accessToken, setAccessToken] = useState('');
const [templateName, setTemplateName] = useState('gallery_ready');
const [enabled, setEnabled] = useState(false);
const [showToken, setShowToken] = useState(false);
const [testPhone, setTestPhone] = useState('');
useEffect(() => {
if (data) {
setPhoneNumberId(data.phone_number_id || '');
setWabaId(data.waba_id || '');
// Server returns '********' when a token is stored, '' when none is.
// Leave it visible-as-masked so the admin sees that a token exists.
setAccessToken(data.access_token || '');
setTemplateName(data.template_name || 'gallery_ready');
setEnabled(Boolean(data.enabled));
}
}, [data]);
const save = useMutation({
mutationFn: () => whatsappService.updateConfig({
phone_number_id: phoneNumberId,
waba_id: wabaId,
access_token: accessToken,
template_name: templateName,
enabled,
}),
onSuccess: () => {
toast.success(t('settings.whatsapp.savedToast', 'WhatsApp settings saved.'));
qc.invalidateQueries({ queryKey: ['whatsapp-config'] });
},
onError: (e: any) => {
toast.error(e?.response?.data?.error || e.message || 'Save failed');
},
});
const sendTest = useMutation({
mutationFn: () => whatsappService.sendTest(testPhone),
onSuccess: (r) => {
toast.success(
t('settings.whatsapp.testSentToast', 'Test message sent (id: {{id}}).', {
id: r.messageId || 'unknown',
}),
);
},
onError: (e: any) => {
toast.error(e?.response?.data?.error || e.message || 'Test send failed');
},
});
if (isLoading) return <Loading />;
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
{t('settings.whatsapp.title', 'WhatsApp')}
</h2>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t(
'settings.whatsapp.subtitle',
'Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.',
)}
</p>
</div>
<Card>
<CardContent className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.whatsapp.phoneNumberId', 'Phone Number ID')}
</label>
<Input
value={phoneNumberId}
onChange={(e) => setPhoneNumberId(e.target.value)}
placeholder="123456789012345"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t(
'settings.whatsapp.phoneNumberIdHint',
'From Meta Business → WhatsApp → API Setup. The numeric ID Meta assigns to the phone you registered.',
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.whatsapp.wabaId', 'WABA ID')}
</label>
<Input
value={wabaId}
onChange={(e) => setWabaId(e.target.value)}
placeholder="123456789012345"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t(
'settings.whatsapp.wabaIdHint',
'WhatsApp Business Account ID. Reference only (the API call uses the Phone Number ID); helpful for auditing.',
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.whatsapp.accessToken', 'Access token')}
</label>
<Input
type={showToken ? 'text' : 'password'}
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder={t('settings.whatsapp.accessTokenPlaceholder', 'EAAB… (system-user token recommended)') as string}
rightIcon={
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="p-1"
aria-label={showToken ? 'Hide' : 'Show'}
>
{showToken ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t(
'settings.whatsapp.accessTokenHint',
'Stored masked as "********" on GET. Leave the masked value to keep the existing token; type a new one to replace it.',
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.whatsapp.templateName', 'Template name')}
</label>
<Input
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="gallery_ready"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t(
'settings.whatsapp.templateNameHint',
'Name of the Meta-approved message template. The default `gallery_ready` expects 5 body parameters: customer name, event name, gallery link, password line, expiry date. Approve the template in Meta Business Manager before enabling.',
)}
</p>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="rounded border-neutral-300"
/>
{t('settings.whatsapp.enabled', 'Send WhatsApp notifications')}
</label>
<Button
onClick={() => save.mutate()}
disabled={save.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}
</Button>
</CardContent>
</Card>
{/* Test send card — separate so the admin sees it as a distinct action,
not a sub-step of saving. */}
<Card>
<CardContent className="p-5 space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('settings.whatsapp.testHeading', 'Send a test message')}
</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'settings.whatsapp.testHelp',
'Sends a static template message to the phone number below to verify Meta credentials + template approval. Includes country code (e.g. +49…).',
)}
</p>
<div className="flex gap-2 items-start">
<Input
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
placeholder="+49123456789"
className="max-w-xs"
/>
<Button
variant="outline"
onClick={() => sendTest.mutate()}
disabled={!testPhone.trim() || sendTest.isPending}
leftIcon={<Send className="w-4 h-4" />}
>
{sendTest.isPending
? t('settings.whatsapp.testSending', 'Sending…')
: t('settings.whatsapp.testSend', 'Send test')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
export default WhatsAppTab;
+28 -2
View File
@@ -1761,6 +1761,10 @@
"title": "Projekte",
"description": "Nur-Admin-Gruppierungsebene über Events. Bündle mehrere Events unter einem Projekt und öffne ein 360°-Projektübersichts-Cockpit — Meilenstein-Zeitleiste plus ein datierter Verlauf aller E-Mails (mit der tatsächlich gesendeten Vorschau + Erneut-senden/Abbrechen/Wiederholen-Aktionen), Angebote, Verträge, Rechnungen, Galerien und erfassten Stunden. Fügt beim Erfassen von Stunden eine „Auf Projekt buchen“-Option hinzu. Kunden sehen Projekte nie.",
"sidebar": "Übersicht"
},
"whatsapp": {
"title": "WhatsApp",
"description": "Liefert die Gallerie-Bereit-Benachrichtigung zusätzlich zur E-Mail über die WhatsApp Business API. Voraussetzung: Meta-Business-Konto, genehmigte Nachrichtenvorlage und eine Kunden-Telefonnummer am Event. Zugangsdaten unter Einstellungen → WhatsApp konfigurieren."
}
},
"customerSurface": {
@@ -1807,6 +1811,26 @@
"hourlyRatePlaceholder": "z. B. 120.00",
"hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen."
}
},
"whatsapp": {
"title": "WhatsApp",
"subtitle": "Meta-Business-Zugangsdaten konfigurieren, um die Gallerie-Bereit-Benachrichtigung zusätzlich zur E-Mail per WhatsApp zu senden.",
"phoneNumberId": "Phone Number ID",
"phoneNumberIdHint": "Aus Meta Business → WhatsApp → API-Einrichtung. Die numerische ID, die Meta der hinterlegten Telefonnummer zuweist.",
"wabaId": "WABA-ID",
"wabaIdHint": "WhatsApp-Business-Konto-ID. Nur als Referenz (der API-Aufruf nutzt die Phone Number ID); nützlich für Audits.",
"accessToken": "Zugriffs-Token",
"accessTokenPlaceholder": "EAAB… (System-User-Token empfohlen)",
"accessTokenHint": "Beim Abruf maskiert als \"********\" gespeichert. Maskierten Wert beibehalten, um das bestehende Token zu behalten; neuen Wert eingeben, um zu ersetzen.",
"templateName": "Vorlagenname",
"templateNameHint": "Name der von Meta genehmigten Nachrichtenvorlage. Die Standardvorlage `gallery_ready` erwartet 5 Body-Parameter: Kundenname, Event-Name, Galerie-Link, Passwortzeile, Ablaufdatum. Die Vorlage vor der Aktivierung im Meta Business Manager genehmigen lassen.",
"enabled": "WhatsApp-Benachrichtigungen senden",
"savedToast": "WhatsApp-Einstellungen gespeichert.",
"testHeading": "Testnachricht senden",
"testHelp": "Sendet eine statische Vorlagennachricht an die angegebene Telefonnummer, um Meta-Zugangsdaten und Vorlagenfreigabe zu prüfen. Mit Ländervorwahl (z. B. +49…).",
"testSend": "Test senden",
"testSending": "Senden…",
"testSentToast": "Testnachricht gesendet (ID: {{id}})."
}
},
"branding": {
@@ -2284,7 +2308,8 @@
"feedbackDeleted": "Feedback gelöscht",
"feedbackModerated": "Feedback moderiert",
"feedbackSettingsUpdated": "Feedback-Einstellungen aktualisiert für {{eventName}}",
"wordFilterAdded": "Wortfilter hinzugefügt: {{word}}"
"wordFilterAdded": "Wortfilter hinzugefügt: {{word}}",
"whatsappConfigUpdated": "WhatsApp-Konfiguration aktualisiert"
},
"notificationToasts": {
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
@@ -2537,7 +2562,8 @@
"admin_user_deleted": "Admin-Konto gelöscht: {{username}}",
"email_queue_flushed": "E-Mail-Warteschlange geleert",
"email_template_created": "E-Mail-Vorlage erstellt: {{template_key}}",
"event_duplicated": "Event dupliziert aus {{source_event_name}}"
"event_duplicated": "Event dupliziert aus {{source_event_name}}",
"whatsapp_config_updated": "WhatsApp-Konfiguration aktualisiert"
}
},
"acceptInvitation": {
+28 -2
View File
@@ -1319,6 +1319,10 @@
"title": "Projects",
"description": "Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a \"book to project\" control when logging hours. Customers never see projects.",
"sidebar": "Overview"
},
"whatsapp": {
"title": "WhatsApp",
"description": "Deliver the gallery-ready notification via WhatsApp Business API in addition to email. Requires a Meta Business Account, an approved message template, and a customer phone number on the event. Configure credentials under Settings → WhatsApp."
}
},
"customerSurface": {
@@ -1365,6 +1369,26 @@
"hourlyRatePlaceholder": "e.g. 120.00",
"hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate."
}
},
"whatsapp": {
"title": "WhatsApp",
"subtitle": "Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.",
"phoneNumberId": "Phone Number ID",
"phoneNumberIdHint": "From Meta Business → WhatsApp → API Setup. The numeric ID Meta assigns to the phone you registered.",
"wabaId": "WABA ID",
"wabaIdHint": "WhatsApp Business Account ID. Reference only (the API call uses the Phone Number ID); helpful for auditing.",
"accessToken": "Access token",
"accessTokenPlaceholder": "EAAB… (system-user token recommended)",
"accessTokenHint": "Stored masked as \"********\" on GET. Leave the masked value to keep the existing token; type a new one to replace it.",
"templateName": "Template name",
"templateNameHint": "Name of the Meta-approved message template. The default `gallery_ready` expects 5 body parameters: customer name, event name, gallery link, password line, expiry date. Approve the template in Meta Business Manager before enabling.",
"enabled": "Send WhatsApp notifications",
"savedToast": "WhatsApp settings saved.",
"testHeading": "Send a test message",
"testHelp": "Sends a static template message to the phone number below to verify Meta credentials + template approval. Includes country code (e.g. +49…).",
"testSend": "Send test",
"testSending": "Sending…",
"testSentToast": "Test message sent (id: {{id}})."
}
},
"analytics": {
@@ -1871,7 +1895,8 @@
"feedbackDeleted": "Feedback deleted",
"feedbackModerated": "Feedback moderated",
"feedbackSettingsUpdated": "Feedback settings updated for {{eventName}}",
"wordFilterAdded": "Word filter added: {{word}}"
"wordFilterAdded": "Word filter added: {{word}}",
"whatsappConfigUpdated": "WhatsApp configuration updated"
},
"notificationToasts": {
"markedAllRead": "All notifications marked as read",
@@ -2126,7 +2151,8 @@
"admin_user_deleted": "Admin user deleted: {{username}}",
"email_queue_flushed": "Email queue flushed",
"email_template_created": "Email template created: {{template_key}}",
"event_duplicated": "Event duplicated from {{source_event_name}}"
"event_duplicated": "Event duplicated from {{source_event_name}}",
"whatsapp_config_updated": "WhatsApp configuration updated"
}
},
"acceptInvitation": {
+9 -3
View File
@@ -41,6 +41,7 @@ import {
ApiTokensTab,
WebhooksTab,
AccountingTab,
WhatsAppTab,
} from '../../features/settings';
import { EmailConfigPage } from './EmailConfigPage';
import { BrandingPage } from './BrandingPage';
@@ -53,7 +54,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage';
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
import { BlockLibraryPage } from './contracts/BlockLibraryPage';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { Briefcase, Receipt, ScrollText, Mail, Landmark } from 'lucide-react';
import { Briefcase, Receipt, ScrollText, Mail, Landmark, Smartphone } from 'lucide-react';
// Tab keys driving the inner-nav. Must include every key used in
// `navGroups` below and in the switch at the bottom of the component.
@@ -83,7 +84,8 @@ type TabType =
| 'crm'
| 'contracts'
| 'reminderTemplates'
| 'accounting';
| 'accounting'
| 'whatsapp';
interface NavItem {
key: TabType;
@@ -103,7 +105,7 @@ const ALL_TAB_KEYS: TabType[] = [
'security', 'imageSecurity', 'seo',
'apiTokens', 'webhooks',
'status', 'analytics', 'backup',
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting',
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp',
];
function isValidTab(value: string | null): value is TabType {
@@ -256,6 +258,9 @@ export const SettingsPage: React.FC = () => {
...(flags.accounting
? [{ key: 'accounting' as const, label: t('settings.accounting.title', 'Accounting'), icon: Landmark }]
: []),
...(flags.whatsapp
? [{ key: 'whatsapp' as const, label: t('settings.whatsapp.title', 'WhatsApp'), icon: Smartphone }]
: []),
],
},
{
@@ -420,6 +425,7 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'contracts' && <BlockLibraryPage />}
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
{activeTab === 'accounting' && <AccountingTab />}
{activeTab === 'whatsapp' && <WhatsAppTab />}
{activeTab === 'status' && (
<StatusTab
@@ -58,7 +58,12 @@ export type FeatureKey =
// Projects (migration 120). Admin-only grouping layer above events with the
// 360° Project Overview cockpit + the "book to project" hours control. Off
// by default; gates the CRM → Overview area entirely.
| 'projects';
| 'projects'
// WhatsApp Business API delivery channel (migration 136, #640D).
// Strictly opt-in — requires a Meta Business Account, an approved
// message template, and a Meta access token. Independent of email; both
// can fire on the same event.
| 'whatsapp';
export type FeatureFlags = Record<FeatureKey, boolean>;
+37
View File
@@ -0,0 +1,37 @@
import { api } from '../config/api';
/**
* WhatsApp Business API admin config (#640D). The access token is masked on
* GET — the server returns `'********'` when a token is stored, the empty
* string when none is. The PUT silently preserves the stored token if the
* masked sentinel is sent back unchanged.
*/
export interface WhatsAppConfig {
phone_number_id: string;
waba_id: string;
access_token: string; // masked '********' on GET when a real token is stored
template_name: string;
enabled: boolean;
}
export const whatsappService = {
async getConfig(): Promise<WhatsAppConfig> {
const response = await api.get<WhatsAppConfig>('/admin/whatsapp/config');
return response.data;
},
async updateConfig(config: Partial<WhatsAppConfig>): Promise<{ success: true }> {
const response = await api.put<{ success: true }>('/admin/whatsapp/config', config);
return response.data;
},
// Sends a static test message to the supplied phone number using the
// currently-saved config. Returns the Meta message ID on success.
async sendTest(phone: string): Promise<{ success: boolean; messageId?: string }> {
const response = await api.post<{ success: boolean; messageId?: string }>(
'/admin/whatsapp/test',
{ phone },
);
return response.data;
},
};