-- ============================================================================= -- Communication Center V1.1 — Contacts + Import Foundation -- Fully idempotent: safe to re-run after partial failure -- Additive over V1 (migration 016): -- - Adds communication_contacts table -- - Adds DB trigger to auto-create contacts from orders -- - Adds new RPCs for contact CRUD, import, and opt-out -- - Rewrites preview_campaign_audience to use communication_contacts as primary -- - Updates send_campaign to resolve through communication_contacts -- - Updates opt_out_customer to also set unsubscribed_at on contacts -- ============================================================================= CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- ── Table ──────────────────────────────────────────────────────────────────── -- Uses CREATE TABLE IF NOT EXISTS so this is safe to re-run — no data loss. CREATE TABLE IF NOT EXISTS public.communication_contacts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, email TEXT, phone TEXT, first_name TEXT, last_name TEXT, full_name TEXT, source TEXT NOT NULL, -- 'order' | 'import' | 'manual' | 'admin' external_id TEXT, customer_id UUID, email_opt_in BOOLEAN NOT NULL DEFAULT true, sms_opt_in BOOLEAN NOT NULL DEFAULT false, email_opt_in_at TIMESTAMPTZ, sms_opt_in_at TIMESTAMPTZ, unsubscribed_at TIMESTAMPTZ, tags TEXT[] DEFAULT '{}', metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT chk_has_email_or_phone CHECK (email IS NOT NULL OR phone IS NOT NULL) ); -- ── Indexes ───────────────────────────────────────────────────────────────── CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_email_unique ON public.communication_contacts(brand_id, email) WHERE email IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_phone_unique ON public.communication_contacts(brand_id, phone) WHERE phone IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_communication_contacts_brand ON public.communication_contacts(brand_id); CREATE INDEX IF NOT EXISTS idx_communication_contacts_customer ON public.communication_contacts(customer_id) WHERE customer_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_communication_contacts_unsubscribed ON public.communication_contacts(brand_id, unsubscribed_at) WHERE unsubscribed_at IS NULL; CREATE INDEX IF NOT EXISTS idx_communication_contacts_source ON public.communication_contacts(brand_id, source); -- ── RLS ───────────────────────────────────────────────────────────────────── ALTER TABLE public.communication_contacts ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "Platform admin can read communication_contacts" ON public.communication_contacts; CREATE POLICY "Platform admin can read communication_contacts" ON public.communication_contacts FOR SELECT TO authenticated USING (EXISTS ( SELECT 1 FROM admin_users WHERE admin_users.user_id = auth.uid() AND admin_users.role = 'platform_admin' )); DROP POLICY IF EXISTS "Brand admin can read own brand communication_contacts" ON public.communication_contacts; CREATE POLICY "Brand admin can read own brand communication_contacts" ON public.communication_contacts FOR SELECT TO authenticated USING (brand_id IN ( SELECT brand_id FROM admin_users WHERE admin_users.user_id = auth.uid() AND admin_users.role = 'brand_admin' )); -- ── SECURITY DEFINER RPCs ──────────────────────────────────────────────────── -- 1. Trigger function for auto-creating contact from order -- PostgreSQL trigger functions MUST use RETURNS TRIGGER and access NEW/OLD directly. DROP FUNCTION IF EXISTS public.upsert_contact_from_order(); CREATE OR REPLACE FUNCTION public.upsert_contact_from_order() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_brand_id UUID; BEGIN -- Skip if no email (can't upsert without a key) IF NEW.customer_email IS NULL OR NEW.customer_email = '' THEN RETURN NEW; END IF; -- Resolve brand_id from the stop SELECT brand_id INTO v_brand_id FROM public.stops WHERE id = NEW.stop_id; IF NOT FOUND THEN RETURN NEW; -- stop not found; skip silently END IF; INSERT INTO public.communication_contacts (brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata) VALUES ( v_brand_id, NEW.customer_email, NEW.customer_phone, NEW.customer_name, 'order', NEW.customer_id, true, jsonb_build_object( 'last_order_id', NEW.id, 'last_order_at', now()::TEXT, 'stop_id', NEW.stop_id::TEXT ) ) ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), phone = COALESCE(EXCLUDED.phone, communication_contacts.phone), customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), metadata = jsonb_build_object( 'last_order_id', communication_contacts.metadata->>'last_order_id', 'last_order_at', communication_contacts.metadata->>'last_order_at', 'stop_id', NEW.stop_id::TEXT ), updated_at = now(); -- NOTE: email_opt_in, unsubscribed_at, sms_opt_in are intentionally NOT -- updated here — we preserve the existing opt-out status. RETURN NEW; END; $$; -- 2. Trigger on orders INSERT -- Trigger functions receive NEW implicitly; no arguments after func name. DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders; CREATE TRIGGER trg_create_contact_from_order AFTER INSERT ON public.orders FOR EACH ROW EXECUTE FUNCTION public.upsert_contact_from_order(); -- 3. upsert_communication_contact (general upsert by email or phone) DROP FUNCTION IF EXISTS public.upsert_communication_contact( UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN, TEXT[], JSONB ); CREATE OR REPLACE FUNCTION public.upsert_communication_contact( p_id UUID, p_brand_id UUID, p_email TEXT, p_phone TEXT, p_first_name TEXT, p_last_name TEXT, p_full_name TEXT, p_source TEXT, p_external_id TEXT, p_customer_id UUID, p_email_opt_in BOOLEAN, p_sms_opt_in BOOLEAN, p_tags TEXT[], p_metadata JSONB ) RETURNS communication_contacts LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_result communication_contacts; v_id UUID; BEGIN v_id := coalesce(p_id, uuid_generate_v4()); IF p_email IS NOT NULL AND p_email != '' THEN INSERT INTO public.communication_contacts (id, brand_id, email, phone, first_name, last_name, full_name, source, external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata, email_opt_in_at, sms_opt_in_at) VALUES (v_id, p_brand_id, p_email, p_phone, p_first_name, p_last_name, p_full_name, p_source, p_external_id, p_customer_id, coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false), coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'), CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END, CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END) ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET phone = COALESCE(EXCLUDED.phone, communication_contacts.phone), first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name), last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name), full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), source = COALESCE(EXCLUDED.source, communication_contacts.source), external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id), customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), email_opt_in = CASE WHEN communication_contacts.unsubscribed_at IS NOT NULL THEN communication_contacts.email_opt_in ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in) END, sms_opt_in = CASE WHEN communication_contacts.unsubscribed_at IS NOT NULL THEN communication_contacts.sms_opt_in ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in) END, tags = COALESCE(EXCLUDED.tags, communication_contacts.tags), metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'), updated_at = now() RETURNING * INTO v_result; ELSIF p_phone IS NOT NULL AND p_phone != '' THEN INSERT INTO public.communication_contacts (id, brand_id, email, phone, first_name, last_name, full_name, source, external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata, email_opt_in_at, sms_opt_in_at) VALUES (v_id, p_brand_id, NULL, p_phone, p_first_name, p_last_name, p_full_name, p_source, p_external_id, p_customer_id, coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false), coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'), CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END, CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END) ON CONFLICT (brand_id, phone) WHERE phone IS NOT NULL DO UPDATE SET email = COALESCE(EXCLUDED.email, communication_contacts.email), first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name), last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name), full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name), source = COALESCE(EXCLUDED.source, communication_contacts.source), external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id), customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id), email_opt_in = CASE WHEN communication_contacts.unsubscribed_at IS NOT NULL THEN communication_contacts.email_opt_in ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in) END, sms_opt_in = CASE WHEN communication_contacts.unsubscribed_at IS NOT NULL THEN communication_contacts.sms_opt_in ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in) END, tags = COALESCE(EXCLUDED.tags, communication_contacts.tags), metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'), updated_at = now() RETURNING * INTO v_result; ELSE RETURN NULL; END IF; RETURN v_result; END; $$; -- 4. get_communication_contacts (paginated list with search) DROP FUNCTION IF EXISTS public.get_communication_contacts(UUID, TEXT, TEXT, INTEGER, INTEGER); CREATE OR REPLACE FUNCTION public.get_communication_contacts( p_brand_id UUID, p_search TEXT DEFAULT NULL, p_source TEXT DEFAULT NULL, p_limit INTEGER DEFAULT 100, p_offset INTEGER DEFAULT 0 ) RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_contacts JSONB; v_total INTEGER; BEGIN SELECT COUNT(*) INTO v_total FROM public.communication_contacts c WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id) AND (p_source IS NULL OR c.source = p_source) AND ( p_search IS NULL OR c.email ILIKE '%' || p_search || '%' OR c.full_name ILIKE '%' || p_search || '%' OR c.phone ILIKE '%' || p_search || '%' ); SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_contacts FROM ( SELECT c.* FROM public.communication_contacts c WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id) AND (p_source IS NULL OR c.source = p_source) AND ( p_search IS NULL OR c.email ILIKE '%' || p_search || '%' OR c.full_name ILIKE '%' || p_search || '%' OR c.phone ILIKE '%' || p_search || '%' ) ORDER BY c.created_at DESC LIMIT p_limit OFFSET p_offset ) c; RETURN jsonb_build_object( 'contacts', v_contacts, 'total', v_total, 'limit', p_limit, 'offset', p_offset ); END; $$; -- 5. import_communication_contacts_batch (bulk CSV upsert) -- Returns: { created: n, updated: n, skipped: n, errors: [...] } -- tags are stored as TEXT[]; CSV string "tag1;tag2" is split with string_to_array. DROP FUNCTION IF EXISTS public.import_communication_contacts_batch(UUID, JSONB, BOOLEAN); CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch( p_brand_id UUID, p_contacts JSONB, p_allow_opt_in_override BOOLEAN DEFAULT false ) RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_entry JSONB; v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB; v_existing communication_contacts%ROWTYPE; v_email TEXT; v_phone TEXT; v_tags TEXT[]; BEGIN FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts) LOOP v_email := nullif(v_entry->>'email', ''); v_phone := nullif(v_entry->>'phone', ''); -- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[] IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN v_tags := coalesce( (SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''), '{}' ); ELSE v_tags := '{}'; END IF; BEGIN IF v_email IS NOT NULL AND v_email != '' THEN SELECT * INTO v_existing FROM public.communication_contacts WHERE brand_id = p_brand_id AND email = v_email; IF FOUND THEN -- Existing contact IF v_existing.unsubscribed_at IS NOT NULL AND coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND p_allow_opt_in_override = false THEN v_result := jsonb_set(v_result, '{skipped}', to_jsonb((v_result->>'skipped')::INTEGER + 1)); ELSE UPDATE public.communication_contacts SET phone = COALESCE(nullif(v_entry->>'phone', ''), phone), first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), source = 'import', external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id), email_opt_in = CASE WHEN communication_contacts.unsubscribed_at IS NOT NULL THEN communication_contacts.email_opt_in ELSE coalesce( (v_entry->>'email_opt_in')::BOOLEAN, communication_contacts.email_opt_in, true ) END, sms_opt_in = CASE WHEN communication_contacts.unsubscribed_at IS NOT NULL THEN communication_contacts.sms_opt_in ELSE coalesce( (v_entry->>'sms_opt_in')::BOOLEAN, communication_contacts.sms_opt_in, false ) END, email_opt_in_at = CASE WHEN communication_contacts.unsubscribed_at IS NOT NULL THEN email_opt_in_at WHEN coalesce( (v_entry->>'email_opt_in')::BOOLEAN, communication_contacts.email_opt_in, true ) = true THEN now() ELSE email_opt_in_at END, tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END, metadata = communication_contacts.metadata || jsonb_build_object( 'imported_at', now()::TEXT ), updated_at = now() WHERE brand_id = p_brand_id AND email = v_email; v_result := jsonb_set(v_result, '{updated}', to_jsonb((v_result->>'updated')::INTEGER + 1)); END IF; ELSE -- Insert new INSERT INTO public.communication_contacts (brand_id, email, phone, first_name, last_name, full_name, source, external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at, tags, metadata) VALUES ( p_brand_id, v_email, nullif(v_entry->>'phone', ''), nullif(v_entry->>'first_name', ''), nullif(v_entry->>'last_name', ''), nullif(v_entry->>'full_name', ''), 'import', nullif(v_entry->>'external_id', ''), coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END, CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END, v_tags, jsonb_build_object('imported_at', now()::TEXT) ); v_result := jsonb_set(v_result, '{created}', to_jsonb((v_result->>'created')::INTEGER + 1)); END IF; ELSIF v_phone IS NOT NULL AND v_phone != '' THEN -- Upsert by phone SELECT * INTO v_existing FROM public.communication_contacts WHERE brand_id = p_brand_id AND phone = v_phone; IF FOUND THEN IF v_existing.unsubscribed_at IS NOT NULL AND coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND p_allow_opt_in_override = false THEN v_result := jsonb_set(v_result, '{skipped}', to_jsonb((v_result->>'skipped')::INTEGER + 1)); ELSE UPDATE public.communication_contacts SET email = COALESCE(nullif(v_entry->>'email', ''), email), first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name), last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name), full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name), source = 'import', email_opt_in = CASE WHEN communication_contacts.unsubscribed_at IS NOT NULL THEN communication_contacts.email_opt_in ELSE coalesce( (v_entry->>'email_opt_in')::BOOLEAN, communication_contacts.email_opt_in, true ) END, tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END, metadata = communication_contacts.metadata || jsonb_build_object( 'imported_at', now()::TEXT ), updated_at = now() WHERE brand_id = p_brand_id AND phone = v_phone; v_result := jsonb_set(v_result, '{updated}', to_jsonb((v_result->>'updated')::INTEGER + 1)); END IF; ELSE INSERT INTO public.communication_contacts (brand_id, email, phone, first_name, last_name, full_name, source, email_opt_in, sms_opt_in, tags, metadata) VALUES ( p_brand_id, nullif(v_entry->>'email', ''), v_phone, nullif(v_entry->>'first_name', ''), nullif(v_entry->>'last_name', ''), nullif(v_entry->>'full_name', ''), 'import', coalesce((v_entry->>'email_opt_in')::BOOLEAN, true), coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false), v_tags, jsonb_build_object('imported_at', now()::TEXT) ); v_result := jsonb_set(v_result, '{created}', to_jsonb((v_result->>'created')::INTEGER + 1)); END IF; ELSE -- No email or phone v_result := jsonb_set( v_result, '{errors}', v_result->'errors' || jsonb_build_array( jsonb_build_object('row', v_entry, 'error', 'No email or phone provided') ) ); END IF; EXCEPTION WHEN OTHERS THEN v_result := jsonb_set( v_result, '{errors}', v_result->'errors' || jsonb_build_array( jsonb_build_object('row', v_entry, 'error', SQLERRM) ) ); END; END LOOP; RETURN v_result; END; $$; -- 6. opt_out_contact (unsubscribe by email — public-facing, no auth required) DROP FUNCTION IF EXISTS public.opt_out_contact(TEXT, UUID, TEXT); CREATE OR REPLACE FUNCTION public.opt_out_contact( p_email TEXT, p_brand_id UUID, p_method TEXT ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN IF p_method = 'email' THEN UPDATE public.communication_contacts SET email_opt_in = false, email_opt_in_at = now(), unsubscribed_at = now(), updated_at = now() WHERE brand_id = p_brand_id AND email = p_email; ELSIF p_method = 'sms' THEN UPDATE public.communication_contacts SET sms_opt_in = false, sms_opt_in_at = now(), unsubscribed_at = now(), updated_at = now() WHERE brand_id = p_brand_id AND (email = p_email OR phone = p_email); END IF; END; $$; -- 7. delete_communication_contact DROP FUNCTION IF EXISTS public.delete_communication_contact(UUID); CREATE OR REPLACE FUNCTION public.delete_communication_contact(p_id UUID) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN DELETE FROM public.communication_contacts WHERE id = p_id; RETURN FOUND; END; $$; -- ── Rewrite preview_campaign_audience to use communication_contacts ─────────── -- Primary source: communication_contacts (opted-in, not unsubscribed) -- Orders-based targeting still works but final recipient resolution is via contacts. DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB); CREATE OR REPLACE FUNCTION public.preview_campaign_audience( p_brand_id UUID, p_audience_rules JSONB ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_target TEXT; v_stop_id UUID; v_date_from TIMESTAMPTZ; v_date_to TIMESTAMPTZ; v_city TEXT; v_order_hist TEXT; v_days_back INTEGER; v_product_id UUID; v_count INTEGER; v_contacts JSONB; BEGIN v_target := p_audience_rules->>'target'; -- Stop-based targeting: find orders for stop+date range, resolve to contacts IF v_target = 'stop' THEN v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID; v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ; v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ; SELECT COUNT(DISTINCT c.id), jsonb_agg(DISTINCT jsonb_build_object( 'id', c.id, 'email', c.email, 'name', c.full_name, 'phone', c.phone, 'source', c.source ) ORDER BY c.full_name) INTO v_count, v_contacts FROM ( SELECT DISTINCT o.customer_email FROM orders o JOIN stops s ON s.id = o.stop_id WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') AND (v_stop_id IS NULL OR o.stop_id = v_stop_id) AND (v_date_from IS NULL OR o.created_at >= v_date_from) AND (v_date_to IS NULL OR o.created_at <= v_date_to) AND o.customer_email IS NOT NULL AND o.customer_email != '' ) o_emails JOIN communication_contacts c ON c.email = o_emails.customer_email AND c.brand_id = p_brand_id AND c.email_opt_in = true AND c.unsubscribed_at IS NULL; -- ZIP / city targeting (placeholder — ZIP requires customer_zip column in orders) ELSIF v_target = 'zip_code' THEN SELECT COUNT(*), jsonb_agg(jsonb_build_object( 'id', c.id, 'email', c.email, 'name', c.full_name, 'phone', c.phone, 'source', c.source ) ORDER BY c.full_name) INTO v_count, v_contacts FROM communication_contacts c WHERE c.brand_id = p_brand_id AND c.email_opt_in = true AND c.unsubscribed_at IS NULL; -- Customer history targeting ELSIF v_target = 'customer_history' THEN v_order_hist := p_audience_rules->>'order_history'; v_days_back := (p_audience_rules->>'days_back')::INTEGER; IF v_order_hist = 'first_order' THEN WITH first_order_emails AS ( SELECT o.customer_email FROM orders o JOIN stops s ON s.id = o.stop_id WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') AND o.customer_email IS NOT NULL AND o.customer_email != '' AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) GROUP BY o.customer_email HAVING COUNT(*) = 1 ) SELECT COUNT(*), jsonb_agg(jsonb_build_object( 'id', c.id, 'email', c.email, 'name', c.full_name, 'phone', c.phone, 'source', c.source ) ORDER BY c.full_name) INTO v_count, v_contacts FROM first_order_emails fe JOIN communication_contacts c ON c.email = fe.customer_email AND c.brand_id = p_brand_id AND c.email_opt_in = true AND c.unsubscribed_at IS NULL; ELSIF v_order_hist = 'repeat' THEN WITH repeat_emails AS ( SELECT o.customer_email FROM orders o JOIN stops s ON s.id = o.stop_id WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') AND o.customer_email IS NOT NULL AND o.customer_email != '' AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) GROUP BY o.customer_email HAVING COUNT(*) > 1 ) SELECT COUNT(*), jsonb_agg(jsonb_build_object( 'id', c.id, 'email', c.email, 'name', c.full_name, 'phone', c.phone, 'source', c.source ) ORDER BY c.full_name) INTO v_count, v_contacts FROM repeat_emails re JOIN communication_contacts c ON c.email = re.customer_email AND c.brand_id = p_brand_id AND c.email_opt_in = true AND c.unsubscribed_at IS NULL; ELSE WITH all_order_emails AS ( SELECT DISTINCT o.customer_email FROM orders o JOIN stops s ON s.id = o.stop_id WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') AND o.customer_email IS NOT NULL AND o.customer_email != '' AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL) ) SELECT COUNT(*), jsonb_agg(jsonb_build_object( 'id', c.id, 'email', c.email, 'name', c.full_name, 'phone', c.phone, 'source', c.source ) ORDER BY c.full_name) INTO v_count, v_contacts FROM all_order_emails aoe JOIN communication_contacts c ON c.email = aoe.customer_email AND c.brand_id = p_brand_id AND c.email_opt_in = true AND c.unsubscribed_at IS NULL; END IF; -- Product-based targeting ELSIF v_target = 'product' THEN v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID; WITH product_order_emails AS ( SELECT DISTINCT o.customer_email FROM order_items oi JOIN orders o ON o.id = oi.order_id JOIN stops s ON s.id = o.stop_id WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled') AND (v_product_id IS NULL OR oi.product_id = v_product_id) AND o.customer_email IS NOT NULL AND o.customer_email != '' ) SELECT COUNT(*), jsonb_agg(jsonb_build_object( 'id', c.id, 'email', c.email, 'name', c.full_name, 'phone', c.phone, 'source', c.source ) ORDER BY c.full_name) INTO v_count, v_contacts FROM product_order_emails poe JOIN communication_contacts c ON c.email = poe.customer_email AND c.brand_id = p_brand_id AND c.email_opt_in = true AND c.unsubscribed_at IS NULL; -- Explicit customer ID list ELSIF v_target = 'customer_ids' THEN SELECT COUNT(*), jsonb_agg(jsonb_build_object( 'id', c.id, 'email', c.email, 'name', c.full_name, 'phone', c.phone, 'source', c.source ) ORDER BY c.full_name) INTO v_count, v_contacts FROM communication_contacts c WHERE c.brand_id = p_brand_id AND c.email_opt_in = true AND c.unsubscribed_at IS NULL AND c.customer_id IN ( SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID ); -- all_customers: all opted-in, not-unsubscribed contacts ELSE SELECT COUNT(*), jsonb_agg(jsonb_build_object( 'id', c.id, 'email', c.email, 'name', c.full_name, 'phone', c.phone, 'source', c.source ) ORDER BY c.full_name) INTO v_count, v_contacts FROM communication_contacts c WHERE c.brand_id = p_brand_id AND c.email_opt_in = true AND c.unsubscribed_at IS NULL; END IF; -- Cap sample at 20 SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_contacts FROM ( SELECT * FROM jsonb_array_elements(coalesce(v_contacts, '[]'::jsonb)) LIMIT 20 ) t(x); RETURN jsonb_build_object('count', v_count, 'sample_customers', v_contacts); END; $$; -- ── Update send_campaign to use communication_contacts ────────────────────── DROP FUNCTION IF EXISTS public.send_campaign(UUID); CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_campaign communication_campaigns; v_settings communication_settings; v_audience JSONB; v_entry JSONB; v_entries JSONB := '[]'::JSONB; v_count INTEGER := 0; BEGIN SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id; IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); END IF; SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id; IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); END IF; v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules); FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb)) LOOP CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = ''; v_entries := v_entries || jsonb_build_array(jsonb_build_object( 'brand_id', v_campaign.brand_id, 'campaign_id', p_campaign_id, 'customer_id', nullif(v_entry->>'id', ''), 'customer_email', v_entry->>'email', 'delivery_method','email', 'subject', v_campaign.subject, 'body_preview', left(v_campaign.body_text, 500), 'status', 'queued' )); v_count := v_count + 1; END LOOP; PERFORM log_communication_messages(v_entries); UPDATE communication_campaigns SET status = 'sent', sent_at = now(), updated_at = now() WHERE id = p_campaign_id; RETURN jsonb_build_object('success', true, 'messages_logged', v_count); END; $$; -- ── Update opt_out_customer to also set unsubscribed_at on contacts ─────────── DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT); CREATE OR REPLACE FUNCTION public.opt_out_customer( p_customer_id UUID, p_brand_id UUID, p_method TEXT ) RETURNS customer_communication_preferences LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_result customer_communication_preferences; BEGIN INSERT INTO public.customer_communication_preferences (customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at) VALUES ( p_customer_id, p_brand_id, CASE WHEN p_method = 'email' THEN false ELSE true END, CASE WHEN p_method = 'sms' THEN false ELSE true END, CASE WHEN p_method = 'email' THEN now() ELSE NULL END, CASE WHEN p_method = 'sms' THEN now() ELSE NULL END ) ON CONFLICT (customer_id, brand_id) DO UPDATE SET email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END, sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END, email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END, sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END, updated_at = now() RETURNING * INTO v_result; -- Also set unsubscribed_at on communication_contacts IF p_method = 'email' THEN UPDATE public.communication_contacts SET email_opt_in = false, email_opt_in_at = now(), unsubscribed_at = now(), updated_at = now() WHERE customer_id = p_customer_id AND brand_id = p_brand_id AND unsubscribed_at IS NULL; ELSIF p_method = 'sms' THEN UPDATE public.communication_contacts SET sms_opt_in = false, sms_opt_in_at = now(), unsubscribed_at = now(), updated_at = now() WHERE customer_id = p_customer_id AND brand_id = p_brand_id AND unsubscribed_at IS NULL; END IF; RETURN v_result; END; $$; NOTIFY pgrst, 'reload schema';