mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Closes the second half of #147. Users can now create a template in wacrm and submit it to Meta for approval without leaving the app — no more dummy local rows that fail with Meta error #132001 on send. API surface ----------- POST /api/whatsapp/templates/submit Auth → fetch whatsapp_config → validate payload → build Meta components → POST to /{waba_id}/message_templates → persist row with meta_template_id + status + sample_values + last_submitted_at. Per-field validators throw with specific messages before the network call, so misconfigured templates fail with "button #3 missing url" instead of a 400 from Meta. 429s are detected and surfaced as "rate limit hit (100/hr)". WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and issues a synthetic `dry-run-<uuid>` meta_template_id so CI and local dev can exercise the full form without a real WABA. New modules ----------- src/lib/whatsapp/template-validators.ts Pure functions encoding Meta's caps: 1024-char body, 60-char footer/header, 25-char button text, 10 buttons max, 2 URL / 1 PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL example required when {{1}} is present, COPY_CODE example required, header media URL must be http(s), variables must be contiguous starting at {{1}}. 41 unit tests cover each rule. src/lib/whatsapp/template-components.ts Translates our local row shape into Meta's components array (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D array per spec, header_handle preferred over header_url when both are present). Snapshot tests assert the wire shape. src/lib/whatsapp/template-status-normalize.ts Single source of truth for upstream→local status mapping. Replaces the duplicated normalizer in sync/route.ts; the upcoming webhook handler will share it too. src/lib/whatsapp/meta-api.ts New submitMessageTemplate() helper following the existing named-args + throwMetaError pattern. Form rewrite ------------ src/components/settings/template-manager.tsx replaces the flat form with a component-based builder: * Header: format select (None / Text / Image / Video / Document) + conditional inputs — text + variable-aware sample row for TEXT, URL paste for media (file upload slot ready for the Resumable Upload follow-up) * Body: textarea with live {{N}} extraction → sample-value rows auto-appear and shrink as variables are added/removed * Footer: 60-char capped input * Buttons editor: add up to 10, per-row type picker (Quick Reply / URL / Phone / Copy Code), conditional URL + example + phone + code fields, X to remove * Card display now surfaces rejection_reason / submission_error (red banner) and quality_score (GREEN/YELLOW/RED chip) * AUTHENTICATION shows a "create in Meta Manager + Sync" banner and disables Submit — that category has a different shape (fixed body, mandatory OTP button) and lands in a follow-up Side effects ------------ * Broadcast Step 1 picker (step1-choose-template.tsx) now filters to status='APPROVED' to match the inbox picker — selecting a non-approved template would 400 on send. * .env.local.example documents META_APP_ID (Resumable Upload follow-up) and WHATSAPP_TEMPLATES_DRY_RUN. Out of scope (subsequent PRs): * Edit / resubmit / delete endpoints (PR 4) * Webhook handler for real-time status (PR 5) * sendTemplateMessage media + button param support (PR 6) * Resumable Upload + AUTHENTICATION builder (follow-up) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
/**
|
|
* Pure validators for message templates, run BEFORE the Meta submit
|
|
* call so a misconfigured template fails at save time (with a specific
|
|
* field-level error) rather than at the Meta API boundary (where the
|
|
* error is a generic 400 + opaque rejection_reason hours later).
|
|
*
|
|
* Every validator throws `Error(message)` — callers catch and surface
|
|
* to the UI. Caps follow Meta's published limits for the Cloud API
|
|
* template surface (v21.0):
|
|
* https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates
|
|
*
|
|
* Per-element button validation lives here rather than as a JSONB CHECK
|
|
* because Postgres CHECK constraints can't contain subqueries, and
|
|
* generic CHECK violations don't give users an actionable error
|
|
* ("button #3 has no `text`" beats "constraint violated").
|
|
*/
|
|
|
|
import type {
|
|
MessageTemplate,
|
|
TemplateButton,
|
|
TemplateSampleValues,
|
|
} from '@/types';
|
|
|
|
export const TEMPLATE_LIMITS = {
|
|
bodyMaxLength: 1024,
|
|
footerMaxLength: 60,
|
|
headerTextMaxLength: 60,
|
|
buttonTextMaxLength: 25,
|
|
maxButtonsTotal: 10,
|
|
maxUrlButtons: 2,
|
|
maxPhoneButtons: 1,
|
|
maxCopyCodeButtons: 1,
|
|
/** Meta: lowercase a-z, digits, underscore. Up to 512 chars. */
|
|
nameRegex: /^[a-z0-9_]{1,512}$/,
|
|
} as const;
|
|
|
|
export interface TemplatePayload {
|
|
name: string;
|
|
category: MessageTemplate['category'];
|
|
language: string;
|
|
header_type?: MessageTemplate['header_type'];
|
|
header_content?: string;
|
|
header_media_url?: string;
|
|
header_handle?: string;
|
|
body_text: string;
|
|
footer_text?: string;
|
|
buttons?: TemplateButton[];
|
|
sample_values?: TemplateSampleValues;
|
|
}
|
|
|
|
export function validateTemplateName(name: string): void {
|
|
if (!name) throw new Error('Template name is required.');
|
|
if (!TEMPLATE_LIMITS.nameRegex.test(name)) {
|
|
throw new Error(
|
|
'Template name must use only lowercase letters, digits, and underscores (1-512 chars).',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract sorted, deduplicated {{N}} indices from a string. Returns
|
|
* `[1, 2, 4]` for `"Hi {{1}} {{2}}, item {{4}}"`.
|
|
*/
|
|
export function extractVariableIndices(text: string): number[] {
|
|
const matches = text.matchAll(/\{\{(\d+)\}\}/g);
|
|
const set = new Set<number>();
|
|
for (const m of matches) {
|
|
const n = Number(m[1]);
|
|
if (Number.isFinite(n) && n >= 1) set.add(n);
|
|
}
|
|
return [...set].sort((a, b) => a - b);
|
|
}
|
|
|
|
/**
|
|
* Meta requires contiguous, 1-indexed variables. `{{1}} {{3}}` is
|
|
* invalid — it must be `{{1}} {{2}}`.
|
|
*/
|
|
function assertContiguous(indices: number[], where: string): void {
|
|
for (let i = 0; i < indices.length; i++) {
|
|
if (indices[i] !== i + 1) {
|
|
throw new Error(
|
|
`${where} variables must be contiguous starting at {{1}} — found ${indices
|
|
.map((n) => `{{${n}}}`)
|
|
.join(', ')}.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function validateBody(bodyText: string): number[] {
|
|
if (!bodyText.trim()) throw new Error('Body text is required.');
|
|
if (bodyText.length > TEMPLATE_LIMITS.bodyMaxLength) {
|
|
throw new Error(
|
|
`Body text exceeds ${TEMPLATE_LIMITS.bodyMaxLength} chars (got ${bodyText.length}).`,
|
|
);
|
|
}
|
|
const indices = extractVariableIndices(bodyText);
|
|
assertContiguous(indices, 'Body');
|
|
return indices;
|
|
}
|
|
|
|
export function validateFooter(footerText: string | undefined): void {
|
|
if (!footerText) return;
|
|
if (footerText.length > TEMPLATE_LIMITS.footerMaxLength) {
|
|
throw new Error(
|
|
`Footer text exceeds ${TEMPLATE_LIMITS.footerMaxLength} chars (got ${footerText.length}).`,
|
|
);
|
|
}
|
|
if (extractVariableIndices(footerText).length > 0) {
|
|
throw new Error('Footer text cannot contain {{N}} variables (Meta rule).');
|
|
}
|
|
}
|
|
|
|
export interface HeaderValidationResult {
|
|
/** number of {{N}} placeholders in a TEXT header — 0 or 1. */
|
|
variableCount: number;
|
|
}
|
|
|
|
export function validateHeader(
|
|
payload: Pick<
|
|
TemplatePayload,
|
|
'header_type' | 'header_content' | 'header_media_url' | 'header_handle'
|
|
>,
|
|
): HeaderValidationResult {
|
|
const { header_type, header_content, header_media_url, header_handle } = payload;
|
|
if (!header_type) return { variableCount: 0 };
|
|
|
|
if (header_type === 'text') {
|
|
if (!header_content || !header_content.trim()) {
|
|
throw new Error('Text header requires header_content.');
|
|
}
|
|
if (header_content.length > TEMPLATE_LIMITS.headerTextMaxLength) {
|
|
throw new Error(
|
|
`Header text exceeds ${TEMPLATE_LIMITS.headerTextMaxLength} chars (got ${header_content.length}).`,
|
|
);
|
|
}
|
|
const indices = extractVariableIndices(header_content);
|
|
if (indices.length > 1) {
|
|
throw new Error(
|
|
`Text header supports at most one variable — found ${indices.length} (Meta rule).`,
|
|
);
|
|
}
|
|
if (indices.length === 1 && indices[0] !== 1) {
|
|
throw new Error('Text header variable must be {{1}} (Meta rule).');
|
|
}
|
|
return { variableCount: indices.length };
|
|
}
|
|
|
|
// image / video / document need either a public URL or a Resumable
|
|
// Upload handle. Either one — Meta accepts both example forms.
|
|
if (!header_media_url && !header_handle) {
|
|
throw new Error(
|
|
`${header_type} header requires either a public sample URL (header_media_url) or a Resumable Upload handle (header_handle).`,
|
|
);
|
|
}
|
|
if (header_media_url) {
|
|
try {
|
|
const u = new URL(header_media_url);
|
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
|
throw new Error('header_media_url must use http(s) scheme.');
|
|
}
|
|
} catch {
|
|
throw new Error('header_media_url must be a valid URL.');
|
|
}
|
|
}
|
|
return { variableCount: 0 };
|
|
}
|
|
|
|
function countButtonsByType(
|
|
buttons: TemplateButton[],
|
|
): Record<TemplateButton['type'], number> {
|
|
const counts: Record<TemplateButton['type'], number> = {
|
|
QUICK_REPLY: 0,
|
|
URL: 0,
|
|
PHONE_NUMBER: 0,
|
|
COPY_CODE: 0,
|
|
};
|
|
for (const b of buttons) counts[b.type]++;
|
|
return counts;
|
|
}
|
|
|
|
export function validateButtons(buttons: TemplateButton[] | undefined): void {
|
|
if (!buttons || buttons.length === 0) return;
|
|
if (buttons.length > TEMPLATE_LIMITS.maxButtonsTotal) {
|
|
throw new Error(
|
|
`Templates can have at most ${TEMPLATE_LIMITS.maxButtonsTotal} buttons (got ${buttons.length}).`,
|
|
);
|
|
}
|
|
|
|
const counts = countButtonsByType(buttons);
|
|
if (counts.URL > TEMPLATE_LIMITS.maxUrlButtons) {
|
|
throw new Error(
|
|
`At most ${TEMPLATE_LIMITS.maxUrlButtons} URL buttons allowed (got ${counts.URL}).`,
|
|
);
|
|
}
|
|
if (counts.PHONE_NUMBER > TEMPLATE_LIMITS.maxPhoneButtons) {
|
|
throw new Error(
|
|
`At most ${TEMPLATE_LIMITS.maxPhoneButtons} PHONE_NUMBER button allowed (got ${counts.PHONE_NUMBER}).`,
|
|
);
|
|
}
|
|
if (counts.COPY_CODE > TEMPLATE_LIMITS.maxCopyCodeButtons) {
|
|
throw new Error(
|
|
`At most ${TEMPLATE_LIMITS.maxCopyCodeButtons} COPY_CODE button allowed (got ${counts.COPY_CODE}).`,
|
|
);
|
|
}
|
|
|
|
// Meta rule: QUICK_REPLY buttons must be contiguous — they can't be
|
|
// interleaved with CTA buttons. Easiest check: walk the array; once
|
|
// we leave the QUICK_REPLY block, we must not see another.
|
|
let sawNonQR = false;
|
|
for (const b of buttons) {
|
|
if (b.type === 'QUICK_REPLY') {
|
|
if (sawNonQR) {
|
|
throw new Error(
|
|
'QUICK_REPLY buttons cannot be interleaved with URL / PHONE_NUMBER / COPY_CODE buttons — group them at the start.',
|
|
);
|
|
}
|
|
} else {
|
|
sawNonQR = true;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < buttons.length; i++) {
|
|
const b = buttons[i];
|
|
if (!b.text?.trim()) {
|
|
throw new Error(`Button #${i + 1} (${b.type}) is missing text.`);
|
|
}
|
|
if (b.text.length > TEMPLATE_LIMITS.buttonTextMaxLength) {
|
|
throw new Error(
|
|
`Button #${i + 1} text exceeds ${TEMPLATE_LIMITS.buttonTextMaxLength} chars.`,
|
|
);
|
|
}
|
|
switch (b.type) {
|
|
case 'URL': {
|
|
if (!b.url?.trim()) {
|
|
throw new Error(`URL button #${i + 1} is missing url.`);
|
|
}
|
|
try {
|
|
new URL(b.url);
|
|
} catch {
|
|
throw new Error(`URL button #${i + 1} has an invalid url.`);
|
|
}
|
|
const urlVars = extractVariableIndices(b.url);
|
|
if (urlVars.length > 1) {
|
|
throw new Error(
|
|
`URL button #${i + 1} can have at most one variable (Meta rule).`,
|
|
);
|
|
}
|
|
if (urlVars.length === 1) {
|
|
if (urlVars[0] !== 1) {
|
|
throw new Error(
|
|
`URL button #${i + 1} variable must be {{1}} (Meta rule).`,
|
|
);
|
|
}
|
|
if (!b.example?.trim()) {
|
|
throw new Error(
|
|
`URL button #${i + 1} uses {{1}} — Meta requires an example value.`,
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'PHONE_NUMBER':
|
|
if (!b.phone_number?.trim()) {
|
|
throw new Error(
|
|
`PHONE_NUMBER button #${i + 1} is missing phone_number.`,
|
|
);
|
|
}
|
|
break;
|
|
case 'COPY_CODE':
|
|
if (!b.example?.trim()) {
|
|
throw new Error(
|
|
`COPY_CODE button #${i + 1} is missing example value.`,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sample values must be supplied 1:1 with the variables in the body
|
|
* (and header, if it has one). Meta uses these for human review.
|
|
*/
|
|
export function validateSampleValues(
|
|
payload: TemplatePayload,
|
|
bodyVarCount: number,
|
|
headerVarCount: number,
|
|
): void {
|
|
const samples = payload.sample_values ?? {};
|
|
const body = samples.body ?? [];
|
|
const header = samples.header ?? [];
|
|
|
|
if (body.length !== bodyVarCount) {
|
|
throw new Error(
|
|
`Body has ${bodyVarCount} variable(s) — supply exactly ${bodyVarCount} sample value(s) (got ${body.length}).`,
|
|
);
|
|
}
|
|
if (header.length !== headerVarCount) {
|
|
throw new Error(
|
|
`Header has ${headerVarCount} variable(s) — supply exactly ${headerVarCount} sample value(s) (got ${header.length}).`,
|
|
);
|
|
}
|
|
for (let i = 0; i < body.length; i++) {
|
|
if (!body[i] || !body[i].trim()) {
|
|
throw new Error(`Body sample value #${i + 1} is empty.`);
|
|
}
|
|
}
|
|
for (let i = 0; i < header.length; i++) {
|
|
if (!header[i] || !header[i].trim()) {
|
|
throw new Error(`Header sample value #${i + 1} is empty.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run every validator. Throws on the first failure with a specific,
|
|
* field-level message. Returns the variable counts so callers can
|
|
* reuse them when building the Meta components payload.
|
|
*/
|
|
export function validateTemplatePayload(payload: TemplatePayload): {
|
|
bodyVarCount: number;
|
|
headerVarCount: number;
|
|
} {
|
|
validateTemplateName(payload.name);
|
|
if (!payload.language?.trim()) {
|
|
throw new Error('Language is required.');
|
|
}
|
|
const bodyVars = validateBody(payload.body_text);
|
|
validateFooter(payload.footer_text);
|
|
const headerResult = validateHeader(payload);
|
|
validateButtons(payload.buttons);
|
|
validateSampleValues(payload, bodyVars.length, headerResult.variableCount);
|
|
return {
|
|
bodyVarCount: bodyVars.length,
|
|
headerVarCount: headerResult.variableCount,
|
|
};
|
|
}
|