wacrm/supabase/migrations/014_message_templates_meta_integration.sql
Arnas Donauskas aa4f34c94c
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>
2026-05-29 12:12:01 +02:00

204 lines
7.5 KiB
SQL

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