feat(templates): store raw Meta status enum + lay schema groundwork for submission

Switches message_templates.status from the local TitleCase set
(Draft|Pending|Approved|Rejected) to Meta's raw enum (DRAFT|PENDING|
APPROVED|REJECTED|PAUSED|DISABLED|IN_APPEAL|PENDING_DELETION) and
adds the columns the upcoming submit / edit / resubmit / webhook
flows need. No new user-facing behavior — this is groundwork.

Migration 014 also adds:

  * sample_values JSONB        Meta requires these for variable templates
  * meta_template_id TEXT      hsm_id for scoped edit/delete (avoids
                               nuking sibling-language variants)
  * rejection_reason TEXT      surfaced from webhook on REJECTED
  * quality_score TEXT         GREEN | YELLOW | RED, from webhook
  * header_handle TEXT         from Resumable Upload (media headers)
  * header_media_url TEXT      URL fallback for media headers (v1 path)
  * submission_error TEXT      last 4xx from Meta, for retry surface
  * last_submitted_at          rate-limit awareness (100 creates/hour)
  * buttons JSONB shape CHECK  permissive array-of-typed-objects guard
                               (strict per-type validation lives in API)
  * UNIQUE (user_id, name, language)
                               so sync upsert is atomic and prevents
                               duplicate language variants
  * idx on meta_template_id    fast webhook lookup

Sync route now:
  * persists Meta's status verbatim (PAUSED no longer collapses to
    REJECTED — these are different states with different recoverability)
  * parses BUTTONS components into the new discriminated-union shape
  * extracts sample_values from body.example.body_text + header.example
  * persists meta_template_id, quality_score, header_handle

Shared template-status.ts gives the manager / inbox picker / broadcast
picker one source of truth for labels + dark-theme badge classes.

Part of the Issue #147 overhaul plan (PR 2 of 6 in the sequence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-29 12:12:01 +02:00
parent 3b0d841533
commit aa4f34c94c
No known key found for this signature in database
7 changed files with 427 additions and 68 deletions

View File

@ -1,57 +1,58 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption' import { decrypt } from '@/lib/whatsapp/encryption'
import type {
MessageTemplateStatus,
TemplateButton,
TemplateSampleValues,
} from '@/types'
/** /**
* Sync message templates from Meta → local message_templates table. * Sync message templates from Meta → local message_templates table.
* *
* Why this exists: * The local catalog stores Meta's status enum verbatim (APPROVED /
* The Settings → Message Templates UI only writes to Supabase. It does * PENDING / REJECTED / PAUSED / DISABLED / IN_APPEAL / PENDING_DELETION)
* NOT submit templates for approval to Meta. Users would create a * so the edit / resubmit / delete flows can distinguish recoverable
* template locally, try to broadcast with it, and hit Meta's error * states (PAUSED) from terminal ones (DISABLED) and so webhook events
* #132001 "Template name does not exist in the translation" — because * land 1:1 without a translation table.
* Meta had never seen the template, or had it approved under a
* different language code than what we stored locally.
* *
* This route pulls the source of truth (Meta's approved templates) * Locally-created templates (no Meta counterpart) are NOT deleted —
* and upserts them into the local catalog by (user_id, name, language). * they remain visible so the user can notice drift and clean up.
* 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.
*/ */
const META_API_VERSION = 'v21.0' const META_API_VERSION = 'v21.0'
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}` 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 { interface MetaTemplateComponent {
type: string type: string
text?: string text?: string
format?: string format?: string
buttons?: MetaButton[]
example?: {
header_text?: string[]
header_handle?: string[]
body_text?: string[][]
}
} }
interface MetaTemplate { interface MetaTemplate {
id: string id: string
name: string name: string
language: string language: string
status: 'APPROVED' | 'PENDING' | 'REJECTED' | 'PAUSED' status: string
category: string category: string
components?: MetaTemplateComponent[] 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( function normalizeCategory(
meta: string, meta: string,
): 'Marketing' | 'Utility' | 'Authentication' { ): 'Marketing' | 'Utility' | 'Authentication' {
@ -61,26 +62,88 @@ function normalizeCategory(
return 'Marketing' return 'Marketing'
} }
/** // Meta sometimes returns PENDING_REVIEW where the docs say PENDING; map
* Meta's template status is UPPERCASE; our DB uses TitleCase. // 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( function normalizeStatus(meta: string): MessageTemplateStatus {
meta: string, const upper = meta.toUpperCase()
): 'Draft' | 'Pending' | 'Approved' | 'Rejected' { if (upper === 'PENDING_REVIEW') return 'PENDING'
switch (meta.toUpperCase()) { const allowed: MessageTemplateStatus[] = [
case 'APPROVED': 'DRAFT',
return 'Approved' 'PENDING',
case 'PENDING': 'APPROVED',
case 'IN_APPEAL': 'REJECTED',
case 'PENDING_DELETION': 'PAUSED',
return 'Pending' 'DISABLED',
case 'REJECTED': 'IN_APPEAL',
case 'DISABLED': 'PENDING_DELETION',
case 'PAUSED': ]
return 'Rejected' return (allowed as string[]).includes(upper)
default: ? (upper as MessageTemplateStatus)
return 'Draft' : '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() { export async function POST() {
@ -96,7 +159,6 @@ export async function POST() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
// whatsapp_config holds waba_id + encrypted access_token.
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
@ -125,14 +187,10 @@ export async function POST() {
const accessToken = decrypt(config.access_token) 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[] = [] const metaTemplates: MetaTemplate[] = []
let nextUrl: let nextUrl:
| string | 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 const PAGE_CAP = 20
let pageCount = 0 let pageCount = 0
@ -161,8 +219,6 @@ export async function POST() {
nextUrl = metaBody.paging?.next ?? null 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 inserted = 0
let updated = 0 let updated = 0
const errors: { name: string; language: string; message: string }[] = [] 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 body = (t.components ?? []).find((c) => c.type === 'BODY')
const header = (t.components ?? []).find((c) => c.type === 'HEADER') const header = (t.components ?? []).find((c) => c.type === 'HEADER')
const footer = (t.components ?? []).find((c) => c.type === 'FOOTER') 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 = { const row = {
user_id: user.id, user_id: user.id,
name: t.name, name: t.name,
category: normalizeCategory(t.category), category: normalizeCategory(t.category),
language: t.language, language: t.language,
header_type: header?.format?.toLowerCase() ?? null, header_type: headerType,
header_content: header?.text ?? null, header_content: header?.text ?? null,
header_handle: header?.example?.header_handle?.[0] ?? null,
body_text: body?.text ?? '', body_text: body?.text ?? '',
footer_text: footer?.text ?? null, footer_text: footer?.text ?? null,
buttons: parsedButtons.length ? parsedButtons : null,
sample_values: sampleValues,
status: normalizeStatus(t.status), status: normalizeStatus(t.status),
meta_template_id: t.id,
quality_score: normalizeQualityScore(t.quality_score),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
} }

View File

@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/client';
import { MessageTemplate } from '@/types'; import { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader2, FileText, ArrowRight } from 'lucide-react'; import { Loader2, FileText, ArrowRight } from 'lucide-react';
import { templateStatusConfig } from '@/lib/template-status';
const categoryColors: Record<string, string> = { const categoryColors: Record<string, string> = {
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20', 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 && (
<> <>
<span>-</span> <span>-</span>
<span>{template.status}</span> <span>{templateStatusConfig[template.status].label}</span>
</> </>
)} )}
</div> </div>

View File

@ -84,7 +84,7 @@ export function TemplatePicker({
.from("message_templates") .from("message_templates")
.select("*") .select("*")
.eq("user_id", user.id) .eq("user_id", user.id)
.eq("status", "Approved") .eq("status", "APPROVED")
.order("created_at", { ascending: false }); .order("created_at", { ascending: false });
if (cancelled) return; if (cancelled) return;

View File

@ -27,6 +27,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import type { MessageTemplate } from '@/types'; import type { MessageTemplate } from '@/types';
import { templateStatusConfig } from '@/lib/template-status';
const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const; const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const;
const HEADER_TYPES = ['text', 'image', 'video', 'document'] as const; const HEADER_TYPES = ['text', 'image', 'video', 'document'] as const;
@ -37,12 +38,6 @@ const categoryColors: Record<string, string> = {
Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30', Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30',
}; };
const statusColors: Record<string, string> = {
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 { interface TemplateFormData {
name: string; name: string;
@ -156,7 +151,7 @@ export function TemplateManager() {
body_text: form.body_text.trim(), body_text: form.body_text.trim(),
header_type: form.header_type || null, header_type: form.header_type || null,
footer_text: form.footer_text.trim() || null, footer_text: form.footer_text.trim() || null,
status: 'Draft' as const, status: 'DRAFT' as const,
}; };
const { error } = await supabase const { error } = await supabase
@ -309,9 +304,9 @@ export function TemplateManager() {
{template.category} {template.category}
</Badge> </Badge>
<Badge <Badge
className={`text-xs border ${statusColors[template.status || 'Draft'] || ''}`} className={`text-xs border ${templateStatusConfig[template.status || 'DRAFT'].classes}`}
> >
{template.status || 'Draft'} {templateStatusConfig[template.status || 'DRAFT'].label}
</Badge> </Badge>
{template.language && ( {template.language && (
<span className="text-xs text-slate-500 uppercase">{template.language}</span> <span className="text-xs text-slate-500 uppercase">{template.language}</span>

View File

@ -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',
},
};

View File

@ -140,6 +140,31 @@ export interface WhatsAppConfig {
connected_at?: string; 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 { export interface MessageTemplate {
id: string; id: string;
user_id: string; user_id: string;
@ -148,10 +173,18 @@ export interface MessageTemplate {
language?: string; language?: string;
header_type?: 'text' | 'image' | 'video' | 'document'; header_type?: 'text' | 'image' | 'video' | 'document';
header_content?: string; header_content?: string;
header_handle?: string;
header_media_url?: string;
body_text: string; body_text: string;
footer_text?: string; footer_text?: string;
buttons?: Record<string, unknown>[]; buttons?: TemplateButton[];
status?: 'Draft' | 'Pending' | 'Approved' | 'Rejected'; 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; created_at: string;
} }

View File

@ -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;