mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
3b0d841533
commit
aa4f34c94c
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>{template.status}</span>
|
||||
<span>{templateStatusConfig[template.status].label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 {
|
||||
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}
|
||||
</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>
|
||||
{template.language && (
|
||||
<span className="text-xs text-slate-500 uppercase">{template.language}</span>
|
||||
|
||||
53
src/lib/template-status.ts
Normal file
53
src/lib/template-status.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
@ -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<string, unknown>[];
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
203
supabase/migrations/014_message_templates_meta_integration.sql
Normal file
203
supabase/migrations/014_message_templates_meta_integration.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user