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 { 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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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