diff --git a/src/app/api/whatsapp/templates/sync/route.ts b/src/app/api/whatsapp/templates/sync/route.ts index 1fbe93b..f267f93 100644 --- a/src/app/api/whatsapp/templates/sync/route.ts +++ b/src/app/api/whatsapp/templates/sync/route.ts @@ -1,57 +1,58 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { decrypt } from '@/lib/whatsapp/encryption' +import type { + MessageTemplateStatus, + TemplateButton, + TemplateSampleValues, +} from '@/types' /** * Sync message templates from Meta → local message_templates table. * - * Why this exists: - * The Settings → Message Templates UI only writes to Supabase. It does - * NOT submit templates for approval to Meta. Users would create a - * template locally, try to broadcast with it, and hit Meta's error - * #132001 "Template name does not exist in the translation" — because - * Meta had never seen the template, or had it approved under a - * different language code than what we stored locally. + * The local catalog stores Meta's status enum verbatim (APPROVED / + * PENDING / REJECTED / PAUSED / DISABLED / IN_APPEAL / PENDING_DELETION) + * so the edit / resubmit / delete flows can distinguish recoverable + * states (PAUSED) from terminal ones (DISABLED) and so webhook events + * land 1:1 without a translation table. * - * This route pulls the source of truth (Meta's approved templates) - * and upserts them into the local catalog by (user_id, name, language). - * After a sync, every local template row is guaranteed to match - * something Meta will actually accept on send. - * - * Scope: - * - Read-only against Meta. We never push local → Meta (template - * submission happens in Meta's WhatsApp Manager and requires human - * review). - * - Only approved templates are surfaced by default. We return - * everything Meta returns and let the UI filter — so the user can - * see their Pending / Rejected templates and understand why. - * - Locally-created templates (no Meta counterpart) are NOT deleted — - * they remain visible so the user can notice drift and clean up - * manually. + * Locally-created templates (no Meta counterpart) are NOT deleted — + * they remain visible so the user can notice drift and clean up. */ const META_API_VERSION = 'v21.0' const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}` +interface MetaButton { + type: string + text: string + url?: string + phone_number?: string + example?: string[] | string +} + interface MetaTemplateComponent { type: string text?: string format?: string + buttons?: MetaButton[] + example?: { + header_text?: string[] + header_handle?: string[] + body_text?: string[][] + } } interface MetaTemplate { id: string name: string language: string - status: 'APPROVED' | 'PENDING' | 'REJECTED' | 'PAUSED' + status: string category: string components?: MetaTemplateComponent[] + quality_score?: { score?: string } | string } -/** - * Meta's template categories are upper-snake (MARKETING / UTILITY / - * AUTHENTICATION); our DB CHECK constraint is TitleCase. Normalize. - */ function normalizeCategory( meta: string, ): 'Marketing' | 'Utility' | 'Authentication' { @@ -61,26 +62,88 @@ function normalizeCategory( return 'Marketing' } -/** - * Meta's template status is UPPERCASE; our DB uses TitleCase. - */ -function normalizeStatus( - meta: string, -): 'Draft' | 'Pending' | 'Approved' | 'Rejected' { - switch (meta.toUpperCase()) { - case 'APPROVED': - return 'Approved' - case 'PENDING': - case 'IN_APPEAL': - case 'PENDING_DELETION': - return 'Pending' - case 'REJECTED': - case 'DISABLED': - case 'PAUSED': - return 'Rejected' - default: - return 'Draft' +// Meta sometimes returns PENDING_REVIEW where the docs say PENDING; map +// it through. Anything we don't recognise falls back to PENDING so the +// row is visible to the user rather than silently dropped. +function normalizeStatus(meta: string): MessageTemplateStatus { + const upper = meta.toUpperCase() + if (upper === 'PENDING_REVIEW') return 'PENDING' + const allowed: MessageTemplateStatus[] = [ + 'DRAFT', + 'PENDING', + 'APPROVED', + 'REJECTED', + 'PAUSED', + 'DISABLED', + 'IN_APPEAL', + 'PENDING_DELETION', + ] + return (allowed as string[]).includes(upper) + ? (upper as MessageTemplateStatus) + : 'PENDING' +} + +function normalizeQualityScore( + raw: MetaTemplate['quality_score'], +): 'GREEN' | 'YELLOW' | 'RED' | null { + const score = + typeof raw === 'string' ? raw : raw?.score ? String(raw.score) : null + if (!score) return null + const upper = score.toUpperCase() + return upper === 'GREEN' || upper === 'YELLOW' || upper === 'RED' + ? (upper as 'GREEN' | 'YELLOW' | 'RED') + : null +} + +function parseButtons(metaButtons: MetaButton[] | undefined): TemplateButton[] { + if (!metaButtons?.length) return [] + const out: TemplateButton[] = [] + for (const b of metaButtons) { + switch (b.type?.toUpperCase()) { + case 'QUICK_REPLY': + out.push({ type: 'QUICK_REPLY', text: b.text }) + break + case 'URL': + out.push({ + type: 'URL', + text: b.text, + url: b.url ?? '', + example: Array.isArray(b.example) ? b.example[0] : b.example, + }) + break + case 'PHONE_NUMBER': + out.push({ + type: 'PHONE_NUMBER', + text: b.text, + phone_number: b.phone_number ?? '', + }) + break + case 'COPY_CODE': + out.push({ + type: 'COPY_CODE', + text: b.text, + example: Array.isArray(b.example) ? b.example[0] ?? '' : b.example ?? '', + }) + break + // OTP, FLOW, etc — out of scope for v1; drop silently. + } } + return out +} + +function extractSampleValues( + body: MetaTemplateComponent | undefined, + header: MetaTemplateComponent | undefined, +): TemplateSampleValues | null { + // Meta returns body_text as a 2D array — one row per example set. + // We take the first row (most templates have exactly one). + const bodySample = body?.example?.body_text?.[0] + const headerSample = header?.example?.header_text + if (!bodySample?.length && !headerSample?.length) return null + const sv: TemplateSampleValues = {} + if (bodySample?.length) sv.body = bodySample + if (headerSample?.length) sv.header = headerSample + return sv } export async function POST() { @@ -96,7 +159,6 @@ export async function POST() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // whatsapp_config holds waba_id + encrypted access_token. const { data: config, error: configError } = await supabase .from('whatsapp_config') .select('*') @@ -125,14 +187,10 @@ export async function POST() { const accessToken = decrypt(config.access_token) - // Paginate through every template Meta has for this WABA. Meta - // returns at most 100 per page; `paging.next` is a full URL. Cap - // at 20 pages (2k templates) as a safety against infinite loops - // from a misbehaving upstream. const metaTemplates: MetaTemplate[] = [] let nextUrl: | string - | null = `${META_API_BASE}/${config.waba_id}/message_templates?limit=100&fields=id,name,language,status,category,components` + | null = `${META_API_BASE}/${config.waba_id}/message_templates?limit=100&fields=id,name,language,status,category,components,quality_score` const PAGE_CAP = 20 let pageCount = 0 @@ -161,8 +219,6 @@ export async function POST() { nextUrl = metaBody.paging?.next ?? null } - // For each Meta template: upsert by (user_id, name, language). - // No UNIQUE constraint on that triple, so we match manually. let inserted = 0 let updated = 0 const errors: { name: string; language: string; message: string }[] = [] @@ -171,17 +227,35 @@ export async function POST() { const body = (t.components ?? []).find((c) => c.type === 'BODY') const header = (t.components ?? []).find((c) => c.type === 'HEADER') const footer = (t.components ?? []).find((c) => c.type === 'FOOTER') + const buttons = (t.components ?? []).find((c) => c.type === 'BUTTONS') + + const parsedButtons = parseButtons(buttons?.buttons) + const sampleValues = extractSampleValues(body, header) + + const headerFormat = header?.format?.toUpperCase() + const headerType = + headerFormat === 'TEXT' || + headerFormat === 'IMAGE' || + headerFormat === 'VIDEO' || + headerFormat === 'DOCUMENT' + ? headerFormat.toLowerCase() + : null const row = { user_id: user.id, name: t.name, category: normalizeCategory(t.category), language: t.language, - header_type: header?.format?.toLowerCase() ?? null, + header_type: headerType, header_content: header?.text ?? null, + header_handle: header?.example?.header_handle?.[0] ?? null, body_text: body?.text ?? '', footer_text: footer?.text ?? null, + buttons: parsedButtons.length ? parsedButtons : null, + sample_values: sampleValues, status: normalizeStatus(t.status), + meta_template_id: t.id, + quality_score: normalizeQualityScore(t.quality_score), updated_at: new Date().toISOString(), } diff --git a/src/components/broadcasts/step1-choose-template.tsx b/src/components/broadcasts/step1-choose-template.tsx index 10f8c80..e3fd476 100644 --- a/src/components/broadcasts/step1-choose-template.tsx +++ b/src/components/broadcasts/step1-choose-template.tsx @@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/client'; import { MessageTemplate } from '@/types'; import { Button } from '@/components/ui/button'; import { Loader2, FileText, ArrowRight } from 'lucide-react'; +import { templateStatusConfig } from '@/lib/template-status'; const categoryColors: Record = { Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20', @@ -106,7 +107,7 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack {template.status && ( <> - - {template.status} + {templateStatusConfig[template.status].label} )} diff --git a/src/components/inbox/template-picker.tsx b/src/components/inbox/template-picker.tsx index 2a3e3bc..2e3e6dd 100644 --- a/src/components/inbox/template-picker.tsx +++ b/src/components/inbox/template-picker.tsx @@ -84,7 +84,7 @@ export function TemplatePicker({ .from("message_templates") .select("*") .eq("user_id", user.id) - .eq("status", "Approved") + .eq("status", "APPROVED") .order("created_at", { ascending: false }); if (cancelled) return; diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index 46d6751..a4e557c 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -27,6 +27,7 @@ import { SelectValue, } from '@/components/ui/select'; import type { MessageTemplate } from '@/types'; +import { templateStatusConfig } from '@/lib/template-status'; const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const; const HEADER_TYPES = ['text', 'image', 'video', 'document'] as const; @@ -37,12 +38,6 @@ const categoryColors: Record = { Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30', }; -const statusColors: Record = { - Draft: 'bg-slate-600/20 text-slate-400 border-slate-600/30', - Pending: 'bg-yellow-600/20 text-yellow-400 border-yellow-600/30', - Approved: 'bg-primary/20 text-primary border-primary/30', - Rejected: 'bg-red-600/20 text-red-400 border-red-600/30', -}; interface TemplateFormData { name: string; @@ -156,7 +151,7 @@ export function TemplateManager() { body_text: form.body_text.trim(), header_type: form.header_type || null, footer_text: form.footer_text.trim() || null, - status: 'Draft' as const, + status: 'DRAFT' as const, }; const { error } = await supabase @@ -309,9 +304,9 @@ export function TemplateManager() { {template.category} - {template.status || 'Draft'} + {templateStatusConfig[template.status || 'DRAFT'].label} {template.language && ( {template.language} diff --git a/src/lib/template-status.ts b/src/lib/template-status.ts new file mode 100644 index 0000000..fc3a11b --- /dev/null +++ b/src/lib/template-status.ts @@ -0,0 +1,53 @@ +/** + * Shared display config for message_templates.status. + * + * The DB stores Meta's raw enum (DRAFT / APPROVED / PENDING / REJECTED / + * PAUSED / DISABLED / IN_APPEAL / PENDING_DELETION) — the UI maps it to + * a human label + dark-theme badge classes here so the template manager, + * inbox picker, and broadcast picker stay aligned. + */ + +import type { MessageTemplateStatus } from '@/types'; + +export interface TemplateStatusDisplay { + label: string; + classes: string; +} + +export const templateStatusConfig: Record< + MessageTemplateStatus, + TemplateStatusDisplay +> = { + DRAFT: { + label: 'Draft', + classes: 'bg-slate-600/20 text-slate-400 border-slate-600/30', + }, + PENDING: { + label: 'Pending', + classes: 'bg-yellow-600/20 text-yellow-400 border-yellow-600/30', + }, + APPROVED: { + label: 'Approved', + classes: 'bg-primary/20 text-primary border-primary/30', + }, + REJECTED: { + label: 'Rejected', + classes: 'bg-red-600/20 text-red-400 border-red-600/30', + }, + PAUSED: { + label: 'Paused', + classes: 'bg-orange-600/20 text-orange-400 border-orange-600/30', + }, + DISABLED: { + label: 'Disabled', + classes: 'bg-red-900/30 text-red-500 border-red-900/40', + }, + IN_APPEAL: { + label: 'In Appeal', + classes: 'bg-blue-600/20 text-blue-400 border-blue-600/30', + }, + PENDING_DELETION: { + label: 'Pending Deletion', + classes: 'bg-slate-700/30 text-slate-500 border-slate-700/40', + }, +}; diff --git a/src/types/index.ts b/src/types/index.ts index e689463..e656a72 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -140,6 +140,31 @@ export interface WhatsAppConfig { connected_at?: string; } +// Raw Meta status enum. We persist this verbatim from Meta (sync + webhook) +// rather than collapsing to a local TitleCase set — distinctions like +// PAUSED vs DISABLED vs IN_APPEAL drive the edit/resubmit/delete flows. +// DRAFT is the local-only state before the row is submitted to Meta. +export type MessageTemplateStatus = + | 'DRAFT' + | 'PENDING' + | 'APPROVED' + | 'REJECTED' + | 'PAUSED' + | 'DISABLED' + | 'IN_APPEAL' + | 'PENDING_DELETION'; + +export type TemplateButton = + | { type: 'QUICK_REPLY'; text: string } + | { type: 'URL'; text: string; url: string; example?: string } + | { type: 'PHONE_NUMBER'; text: string; phone_number: string } + | { type: 'COPY_CODE'; text: string; example: string }; + +export interface TemplateSampleValues { + body?: string[]; + header?: string[]; +} + export interface MessageTemplate { id: string; user_id: string; @@ -148,10 +173,18 @@ export interface MessageTemplate { language?: string; header_type?: 'text' | 'image' | 'video' | 'document'; header_content?: string; + header_handle?: string; + header_media_url?: string; body_text: string; footer_text?: string; - buttons?: Record[]; - status?: 'Draft' | 'Pending' | 'Approved' | 'Rejected'; + buttons?: TemplateButton[]; + sample_values?: TemplateSampleValues; + status?: MessageTemplateStatus; + meta_template_id?: string; + rejection_reason?: string; + quality_score?: 'GREEN' | 'YELLOW' | 'RED'; + submission_error?: string; + last_submitted_at?: string; created_at: string; } diff --git a/supabase/migrations/014_message_templates_meta_integration.sql b/supabase/migrations/014_message_templates_meta_integration.sql new file mode 100644 index 0000000..b617697 --- /dev/null +++ b/supabase/migrations/014_message_templates_meta_integration.sql @@ -0,0 +1,203 @@ +-- ============================================================ +-- message_templates: Meta-integration columns + raw-enum status +-- +-- Why this exists: +-- The original schema (001) treated message_templates as a local +-- catalog with a TitleCase status ('Draft'|'Pending'|'Approved'| +-- 'Rejected'). When the sync route imports from Meta, several of +-- Meta's real statuses (PAUSED, DISABLED, IN_APPEAL, PENDING_REVIEW) +-- got collapsed into the four-bucket TitleCase set — losing +-- information that the upcoming submit / edit / resubmit flows +-- need (e.g. a PAUSED template is recoverable; a DISABLED one is +-- gone for 30 days; an IN_APPEAL one shouldn't be edited). +-- +-- This migration switches `status` to the raw Meta enum and adds +-- the columns the submit/webhook/edit flows need: +-- +-- - sample_values JSONB {body: string[], header: string[]} +-- required by Meta for variable templates +-- - meta_template_id TEXT Meta's id once the template is +-- submitted; used as hsm_id on edit/delete +-- so we scope to a single language +-- - rejection_reason TEXT surfaced from webhook on REJECTED +-- - quality_score TEXT GREEN | YELLOW | RED, from webhook +-- - header_handle TEXT from Resumable Upload, for media headers +-- - header_media_url TEXT URL fallback for media headers (v1 path) +-- - submission_error TEXT last 4xx from Meta on submit, for retry +-- - last_submitted_at rate-limit awareness (100 creates/hour) +-- +-- Also adds a unique index on (user_id, name, language) so the sync +-- upsert can match on it instead of select-then-insert, and so users +-- can't create two local rows for the same Meta template variant. +-- +-- Buttons CHECK enforces a shape guard (array of objects with a +-- recognised `type`) at the DB level — strict per-type validation +-- lives in the API layer so error messages can be specific. +-- +-- Idempotent — safe to re-run. +-- ============================================================ + +-- 1. New columns. ADD COLUMN IF NOT EXISTS is idempotent. +ALTER TABLE message_templates + ADD COLUMN IF NOT EXISTS sample_values JSONB, + ADD COLUMN IF NOT EXISTS meta_template_id TEXT, + ADD COLUMN IF NOT EXISTS rejection_reason TEXT, + ADD COLUMN IF NOT EXISTS quality_score TEXT, + ADD COLUMN IF NOT EXISTS header_handle TEXT, + ADD COLUMN IF NOT EXISTS header_media_url TEXT, + ADD COLUMN IF NOT EXISTS submission_error TEXT, + ADD COLUMN IF NOT EXISTS last_submitted_at TIMESTAMPTZ; + +-- 2. quality_score CHECK — GREEN / YELLOW / RED only (or NULL). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'message_templates_quality_score_check' + AND conrelid = 'message_templates'::regclass + ) THEN + ALTER TABLE message_templates + ADD CONSTRAINT message_templates_quality_score_check + CHECK (quality_score IS NULL OR quality_score IN ('GREEN', 'YELLOW', 'RED')); + END IF; +END $$; + +-- 3. status: swap TitleCase enum for raw Meta enum. +-- Order: drop old check → backfill data → add new check → update default. +-- Doing it in this order means rows are momentarily check-free, but +-- the backfill is a single UPDATE so the window is microseconds. +DO $$ +BEGIN + -- Drop the legacy check by introspecting pg_constraint (the original + -- constraint name from migration 001 is auto-generated; match by + -- column + table). + IF EXISTS ( + SELECT 1 + FROM pg_constraint c + WHERE c.conrelid = 'message_templates'::regclass + AND c.contype = 'c' + AND pg_get_constraintdef(c.oid) ILIKE '%status%Draft%Pending%Approved%Rejected%' + ) THEN + EXECUTE ( + SELECT 'ALTER TABLE message_templates DROP CONSTRAINT ' || quote_ident(conname) + FROM pg_constraint c + WHERE c.conrelid = 'message_templates'::regclass + AND c.contype = 'c' + AND pg_get_constraintdef(c.oid) ILIKE '%status%Draft%Pending%Approved%Rejected%' + LIMIT 1 + ); + END IF; +END $$; + +-- Backfill existing rows. Idempotent — already-uppercase rows are no-ops. +UPDATE message_templates SET status = 'DRAFT' WHERE status = 'Draft'; +UPDATE message_templates SET status = 'PENDING' WHERE status = 'Pending'; +UPDATE message_templates SET status = 'APPROVED' WHERE status = 'Approved'; +UPDATE message_templates SET status = 'REJECTED' WHERE status = 'Rejected'; + +-- Add the raw-enum check. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'message_templates_status_meta_check' + AND conrelid = 'message_templates'::regclass + ) THEN + ALTER TABLE message_templates + ADD CONSTRAINT message_templates_status_meta_check + CHECK (status IN ( + 'DRAFT', + 'PENDING', + 'APPROVED', + 'REJECTED', + 'PAUSED', + 'DISABLED', + 'IN_APPEAL', + 'PENDING_DELETION' + )); + END IF; +END $$; + +-- New default for fresh inserts. +ALTER TABLE message_templates ALTER COLUMN status SET DEFAULT 'DRAFT'; + +-- 4. buttons shape guard. Permissive at the DB level — strict per-type +-- validation (max counts per type, QUICK_REPLY-vs-CTA exclusivity, +-- URL example required when {{1}} is present) lives in the API +-- validators so error messages can be specific. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'message_templates_buttons_shape_check' + AND conrelid = 'message_templates'::regclass + ) THEN + ALTER TABLE message_templates + ADD CONSTRAINT message_templates_buttons_shape_check + CHECK ( + buttons IS NULL + OR ( + jsonb_typeof(buttons) = 'array' + AND jsonb_array_length(buttons) <= 10 + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements(buttons) AS b + WHERE jsonb_typeof(b) <> 'object' + OR NOT (b ? 'type') + OR (b->>'type') NOT IN ( + 'QUICK_REPLY', 'URL', 'PHONE_NUMBER', 'COPY_CODE' + ) + ) + ) + ); + END IF; +END $$; + +-- 5. Unique index on (user_id, name, language). Fails loudly on +-- duplicates rather than dropping rows — the operator picks which +-- one to keep (same pattern as migration 013). +DO $$ +DECLARE + dupe_count INT; + sample TEXT; +BEGIN + SELECT count(*) INTO dupe_count + FROM ( + SELECT user_id, name, language + FROM message_templates + GROUP BY user_id, name, language + HAVING count(*) > 1 + ) dupes; + + IF dupe_count > 0 THEN + SELECT string_agg( + user_id::text || ' / ' || name || ' / ' || COALESCE(language, '(null)') || + ' (' || count || ' rows)', + E'\n ' + ) + INTO sample + FROM ( + SELECT user_id, name, language, count(*) AS count + FROM message_templates + GROUP BY user_id, name, language + HAVING count(*) > 1 + ) dupe_detail; + + RAISE EXCEPTION + E'Cannot add UNIQUE(user_id, name, language) on message_templates — % duplicate combination(s):\n %\nDelete the rows you do not want to keep, then re-run migrations.', + dupe_count, sample; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS message_templates_user_name_language_key + ON message_templates (user_id, name, language); + +-- 6. Lookup index for the webhook handler — incoming events identify +-- templates by (waba_id, meta_template_id). meta_template_id is the +-- discriminator we'll match on. +CREATE INDEX IF NOT EXISTS idx_message_templates_meta_template_id + ON message_templates (meta_template_id) + WHERE meta_template_id IS NOT NULL;