From 78c8e9d9f91d56e07e04df4ed90fb05ccdfb69d2 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 18 Jun 2026 22:57:22 +0200 Subject: [PATCH] feat(whatsapp): WhatsApp Business API notification channel (#640 part D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``. ### 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) --- .../core/136_create_whatsapp_tables.js | 67 +++++ backend/server.js | 10 + backend/src/routes/adminEvents.js | 51 ++++ backend/src/routes/adminFeatureFlags.js | 5 + backend/src/routes/adminWhatsapp.js | 149 ++++++++++ backend/src/services/whatsappProcessor.js | 259 ++++++++++++++++++ backend/src/services/whatsappService.js | 94 +++++++ frontend/src/features/settings/index.ts | 1 + .../features/settings/tabs/FeaturesTab.tsx | 16 ++ .../features/settings/tabs/WhatsAppTab.tsx | 235 ++++++++++++++++ frontend/src/i18n/locales/de.json | 30 +- frontend/src/i18n/locales/en.json | 30 +- frontend/src/pages/admin/SettingsPage.tsx | 12 +- frontend/src/services/featureFlags.service.ts | 7 +- frontend/src/services/whatsapp.service.ts | 37 +++ 15 files changed, 995 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/core/136_create_whatsapp_tables.js create mode 100644 backend/src/routes/adminWhatsapp.js create mode 100644 backend/src/services/whatsappProcessor.js create mode 100644 backend/src/services/whatsappService.js create mode 100644 frontend/src/features/settings/tabs/WhatsAppTab.tsx create mode 100644 frontend/src/services/whatsapp.service.ts diff --git a/backend/migrations/core/136_create_whatsapp_tables.js b/backend/migrations/core/136_create_whatsapp_tables.js new file mode 100644 index 00000000..676f4318 --- /dev/null +++ b/backend/migrations/core/136_create_whatsapp_tables.js @@ -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'); +}; diff --git a/backend/server.js b/backend/server.js index 1f1e0966..7709e394 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 { diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 3054ed80..fc880c6b 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -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, diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 68b3f775..6464aa24 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -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() { diff --git a/backend/src/routes/adminWhatsapp.js b/backend/src/routes/adminWhatsapp.js new file mode 100644 index 00000000..411dbd3f --- /dev/null +++ b/backend/src/routes/adminWhatsapp.js @@ -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; diff --git a/backend/src/services/whatsappProcessor.js b/backend/src/services/whatsappProcessor.js new file mode 100644 index 00000000..b2796273 --- /dev/null +++ b/backend/src/services/whatsappProcessor.js @@ -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, +}; diff --git a/backend/src/services/whatsappService.js b/backend/src/services/whatsappService.js new file mode 100644 index 00000000..c6db3339 --- /dev/null +++ b/backend/src/services/whatsappService.js @@ -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 }; diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index d6a8c40d..1cba2f58 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -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'; diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index 0fda93fb..0f392494 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -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)} /> + setFlag('whatsapp', next)} + /> + { + 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 ; + + return ( +
+
+

+ {t('settings.whatsapp.title', 'WhatsApp')} +

+

+ {t( + 'settings.whatsapp.subtitle', + 'Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.', + )} +

+
+ + + +
+ + setPhoneNumberId(e.target.value)} + placeholder="123456789012345" + /> +

+ {t( + 'settings.whatsapp.phoneNumberIdHint', + 'From Meta Business → WhatsApp → API Setup. The numeric ID Meta assigns to the phone you registered.', + )} +

+
+ +
+ + setWabaId(e.target.value)} + placeholder="123456789012345" + /> +

+ {t( + 'settings.whatsapp.wabaIdHint', + 'WhatsApp Business Account ID. Reference only (the API call uses the Phone Number ID); helpful for auditing.', + )} +

+
+ +
+ + setAccessToken(e.target.value)} + placeholder={t('settings.whatsapp.accessTokenPlaceholder', 'EAAB… (system-user token recommended)') as string} + rightIcon={ + + } + /> +

+ {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.', + )} +

+
+ +
+ + setTemplateName(e.target.value)} + placeholder="gallery_ready" + /> +

+ {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.', + )} +

+
+ + + + +
+
+ + {/* Test send card — separate so the admin sees it as a distinct action, + not a sub-step of saving. */} + + +

+ {t('settings.whatsapp.testHeading', 'Send a test message')} +

+

+ {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…).', + )} +

+
+ setTestPhone(e.target.value)} + placeholder="+49123456789" + className="max-w-xs" + /> + +
+
+
+
+ ); +}; + +export default WhatsAppTab; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index ec76a268..3b77f977 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5adfeac1..a7f273e5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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": { diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 2ca8ffd6..89febb13 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -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' && } {activeTab === 'reminderTemplates' && } {activeTab === 'accounting' && } + {activeTab === 'whatsapp' && } {activeTab === 'status' && ( ; diff --git a/frontend/src/services/whatsapp.service.ts b/frontend/src/services/whatsapp.service.ts new file mode 100644 index 00000000..f557face --- /dev/null +++ b/frontend/src/services/whatsapp.service.ts @@ -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 { + const response = await api.get('/admin/whatsapp/config'); + return response.data; + }, + + async updateConfig(config: Partial): 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; + }, +};