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