diff --git a/migrations/159_repair_sag_email_threading_schema.sql b/migrations/159_repair_sag_email_threading_schema.sql new file mode 100644 index 0000000..549d485 --- /dev/null +++ b/migrations/159_repair_sag_email_threading_schema.sql @@ -0,0 +1,104 @@ +-- Migration 159: Repair SAG email threading schema (idempotent) +-- Purpose: +-- 1) Ensure sag_emails link table exists and is constrained correctly. +-- 2) Ensure email_messages has threading columns used by SAG email tab. +-- 3) Backfill thread_key where missing. + +-- Ensure link table exists +CREATE TABLE IF NOT EXISTS sag_emails ( + sag_id INTEGER REFERENCES sag_sager(id) ON DELETE CASCADE, + email_id INTEGER REFERENCES email_messages(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Ensure required columns on email_messages exist +ALTER TABLE email_messages + ADD COLUMN IF NOT EXISTS in_reply_to VARCHAR(500), + ADD COLUMN IF NOT EXISTS email_references TEXT, + ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500); + +-- Cleanup duplicates before adding unique constraint/PK +WITH ranked AS ( + SELECT ctid, + ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn + FROM sag_emails +) +DELETE FROM sag_emails se +USING ranked r +WHERE se.ctid = r.ctid + AND r.rn > 1; + +-- Ensure PK exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'sag_emails_pkey' + AND conrelid = 'sag_emails'::regclass + ) THEN + ALTER TABLE sag_emails + ADD CONSTRAINT sag_emails_pkey PRIMARY KEY (sag_id, email_id); + END IF; +END $$; + +-- Ensure FKs exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'sag_emails_sag_id_fkey' + AND conrelid = 'sag_emails'::regclass + ) THEN + ALTER TABLE sag_emails + ADD CONSTRAINT sag_emails_sag_id_fkey + FOREIGN KEY (sag_id) REFERENCES sag_sager(id) ON DELETE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'sag_emails_email_id_fkey' + AND conrelid = 'sag_emails'::regclass + ) THEN + ALTER TABLE sag_emails + ADD CONSTRAINT sag_emails_email_id_fkey + FOREIGN KEY (email_id) REFERENCES email_messages(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Helpful indexes for case email tab +CREATE INDEX IF NOT EXISTS idx_sag_emails_sag_id ON sag_emails(sag_id); +CREATE INDEX IF NOT EXISTS idx_sag_emails_email_id ON sag_emails(email_id); +CREATE INDEX IF NOT EXISTS idx_email_messages_thread_key ON email_messages(thread_key) WHERE thread_key IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_email_messages_in_reply_to ON email_messages(in_reply_to) WHERE in_reply_to IS NOT NULL; + +-- Backfill thread_key for rows where it is missing +UPDATE email_messages +SET thread_key = LOWER( + REGEXP_REPLACE( + COALESCE( + NULLIF( + SPLIT_PART( + REGEXP_REPLACE(COALESCE(email_references, ''), '^[\s<>,]+', ''), + ' ', + 1 + ), + '' + ), + NULLIF(in_reply_to, ''), + NULLIF(message_id, '') + ), + '[<>\s]', + '', + 'g' + ) +) +WHERE (thread_key IS NULL OR TRIM(thread_key) = '') + AND COALESCE(NULLIF(email_references, ''), NULLIF(in_reply_to, ''), NULLIF(message_id, '')) IS NOT NULL; + +COMMENT ON TABLE sag_emails IS 'Emails linked to the Case (SAG).'; +COMMENT ON COLUMN email_messages.in_reply_to IS 'Raw In-Reply-To header used for SAG threading lookup'; +COMMENT ON COLUMN email_messages.email_references IS 'Raw References header used for SAG threading lookup'; +COMMENT ON COLUMN email_messages.thread_key IS 'Stable normalized thread key for grouping email conversations';