mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
199 lines
7.4 KiB
SQL
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;
|