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