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:
@@ -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;
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user