wacrm/supabase/migrations/014_message_templates_meta_integration.sql
Arnas Donauskas ddf15184c8
fix(migration-014): drop subquery from buttons CHECK (Postgres disallows it)
Postgres rejects CHECK constraints that contain subqueries with
SQLSTATE 0A000 — `NOT EXISTS (SELECT 1 FROM jsonb_array_elements(...))`
inside CHECK is illegal even though it parses.

Move per-element validation entirely to the API layer (the upcoming
template-validators in PR 3). The DB CHECK keeps the cheap shape
guard: NULL or an array of length <= 10. Per-element validation
needs specific error messages anyway ("button #3 has no `text` field")
which API-layer validators do much better than a CHECK violation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:21:14 +02:00

199 lines
7.4 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. Postgres disallows subqueries in CHECK
-- constraints, so we can only assert the outer shape here (is-array
-- + max length). Per-element type validation (recognised `type`
-- values, max counts per type, QUICK_REPLY-vs-CTA exclusivity, URL
-- example required when {{1}} is present) lives in the API
-- validators in src/lib/whatsapp/template-validators.ts — that's
-- where error messages can be specific to the offending button
-- anyway.
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
)
);
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;