Files
picpeak/frontend/src/services/whatsapp.service.ts
T
Paul Nothaft 16055cdc41 feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.

This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.

## Schema (migration 138)

Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.

## Processor

- `parseTemplateParams(raw)` — defensive parser; falls back to the
  5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
  slots in the listed order, computed via a small switch on slot key.
  The password line still receives the locale-specific 🔒 label and
  the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
  the parsed array to `buildComponents` per message.

## Admin route

- GET surfaces `template_params` as the parsed array (default 5-slot
  when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
  before persisting, so the stored value is always the canonical
  sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
  admin's test message matches their configured slot shape — a
  reporter who configures 2 slots gets a 2-parameter test send, not
  the legacy 5-parameter payload.

## UI

- `WhatsAppTab` gets a checkbox + up/down list under the Template
  language field. Each slot shows its current `{{N}}` position when
  checked, an em-dash when unchecked. Live preview below the list:
  "Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
  human-readable names.

## Tests

- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
  covering: parseTemplateParams sanitization (unknown keys, duplicates,
  non-strings, malformed JSON, all-invalid fallback, pre-parsed array
  acceptance) and buildComponents shape (reporter's 2-slot case,
  reorder, empty list, locale-specific password label, password
  sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.

## Migration numbering

Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.

## Honest caveat

Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
2026-06-21 20:51:20 +02:00

66 lines
2.4 KiB
TypeScript

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.
*/
// Slot keys that map to the built-in `message_data` fields the queue
// processor knows how to substitute. Order = positional `{{N}}` order in the
// Meta-registered template body. Any other string is dropped server-side.
export type WhatsAppTemplateParam =
| 'customer_name'
| 'event_name'
| 'gallery_link'
| 'password_line'
| 'expiry_date';
export const WHATSAPP_TEMPLATE_PARAMS: WhatsAppTemplateParam[] = [
'customer_name',
'event_name',
'gallery_link',
'password_line',
'expiry_date',
];
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;
// Meta template language code (`ar`, `en_US`, `de_DE`, etc.) (#647). Must
// match the language the operator registered with Meta for `template_name`,
// otherwise Meta returns template_not_found_in_language (132001). Empty
// string falls through to general_default_language.
template_language: string;
// Ordered slot list controlling which built-in values are sent as
// positional `{{N}}` parameters to Meta, and in what order (#647
// follow-up). Empty (server-side) falls back to the legacy 5-slot
// gallery_ready shape so existing installs keep working unchanged.
template_params: WhatsAppTemplateParam[];
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;
},
};