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:
@@ -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');
|
||||
};
|
||||
Reference in New Issue
Block a user