Files
route-commerce/supabase/migrations/016_communication_center.sql

662 lines
28 KiB
PL/PgSQL

-- =============================================================================
-- Communication Center V1 — Core Schema
-- Fully idempotent: safe to re-run after partial failure or interrupted deploy
--
-- Cross-brand platform module: supports Tuxedo Corn, Indian River Direct, and
-- future brands via brand_id separation and RLS.
--
-- Design principles:
-- - Operational messaging first (stop notifications, pickup reminders, etc.)
-- - Audience rules owned by campaigns (no separate reusable audience table)
-- - Environment-based provider config (Resend API key in env, not in DB)
-- - Event-driven future-ready (event_type / event_id on message_logs)
-- - No live email/SMS sending until explicitly enabled after testing
-- - Customer data lives in orders table — no separate customers table
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Tables (CREATE TABLE IF NOT EXISTS is already idempotent) ────────────
CREATE TABLE IF NOT EXISTS public.communication_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
default_sender_email TEXT,
default_sender_name TEXT,
reply_to_email TEXT,
email_provider TEXT NOT NULL DEFAULT 'resend',
email_footer_html TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(brand_id)
);
CREATE TABLE IF NOT EXISTS public.communication_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
subject TEXT NOT NULL,
body_text TEXT NOT NULL DEFAULT '',
body_html TEXT,
template_type TEXT NOT NULL,
campaign_type TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.communication_campaigns (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
subject TEXT,
body_text TEXT,
body_html TEXT,
template_id UUID REFERENCES communication_templates(id) ON DELETE SET NULL,
campaign_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
audience_rules JSONB NOT NULL DEFAULT '{}',
scheduled_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.communication_message_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES communication_campaigns(id) ON DELETE SET NULL,
customer_id UUID,
customer_email TEXT,
delivery_method TEXT NOT NULL,
subject TEXT,
body_preview TEXT,
status TEXT NOT NULL,
sent_at TIMESTAMPTZ,
error_message TEXT,
event_type TEXT,
event_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.customer_communication_preferences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
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,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(customer_id, brand_id)
);
-- ── Indexes (CREATE INDEX IF NOT EXISTS is already idempotent) ────────────
CREATE INDEX IF NOT EXISTS idx_communication_settings_brand
ON public.communication_settings(brand_id);
CREATE INDEX IF NOT EXISTS idx_communication_templates_brand
ON public.communication_templates(brand_id);
CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_status
ON public.communication_campaigns(brand_id, status);
CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_type
ON public.communication_campaigns(brand_id, campaign_type);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_brand_sent
ON public.communication_message_logs(brand_id, sent_at DESC);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_campaign
ON public.communication_message_logs(campaign_id);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_customer
ON public.communication_message_logs(customer_id);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_event
ON public.communication_message_logs(event_type, event_id) WHERE event_type IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_customer_comm_prefs_customer_brand
ON public.customer_communication_preferences(customer_id, brand_id);
-- ── RLS — idempotent (ALTER TABLE ENABLE is safe to re-run) ────────────────
ALTER TABLE public.communication_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_message_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.customer_communication_preferences ENABLE ROW LEVEL SECURITY;
-- ── Policies (DROP IF EXISTS before each CREATE makes them idempotent) ────
DROP POLICY IF EXISTS "Platform admin can read communication_settings"
ON public.communication_settings;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_settings"
ON public.communication_settings;
CREATE POLICY "Platform admin can read communication_settings"
ON public.communication_settings FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_settings"
ON public.communication_settings 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'
));
DROP POLICY IF EXISTS "Platform admin can read communication_templates"
ON public.communication_templates;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_templates"
ON public.communication_templates;
CREATE POLICY "Platform admin can read communication_templates"
ON public.communication_templates FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_templates"
ON public.communication_templates 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'
));
DROP POLICY IF EXISTS "Platform admin can read communication_campaigns"
ON public.communication_campaigns;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_campaigns"
ON public.communication_campaigns;
CREATE POLICY "Platform admin can read communication_campaigns"
ON public.communication_campaigns FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_campaigns"
ON public.communication_campaigns 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'
));
DROP POLICY IF EXISTS "Platform admin can read communication_message_logs"
ON public.communication_message_logs;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_message_logs"
ON public.communication_message_logs;
CREATE POLICY "Platform admin can read communication_message_logs"
ON public.communication_message_logs FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_message_logs"
ON public.communication_message_logs 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'
));
DROP POLICY IF EXISTS "Anyone can read customer_communication_preferences"
ON public.customer_communication_preferences;
DROP POLICY IF EXISTS "Platform admin can read customer_communication_preferences"
ON public.customer_communication_preferences;
CREATE POLICY "Anyone can read customer_communication_preferences"
ON public.customer_communication_preferences FOR SELECT TO anon USING (true);
CREATE POLICY "Platform admin can read customer_communication_preferences"
ON public.customer_communication_preferences FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
-- ── SECURITY DEFINER RPCs ─────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.upsert_communication_settings(UUID, TEXT, TEXT, TEXT, TEXT, TEXT);
CREATE OR REPLACE FUNCTION public.upsert_communication_settings(
p_brand_id UUID,
p_sender_email TEXT,
p_sender_name TEXT,
p_reply_to_email TEXT,
p_provider TEXT,
p_footer_html TEXT
)
RETURNS communication_settings
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_result communication_settings;
BEGIN
INSERT INTO public.communication_settings
(brand_id, default_sender_email, default_sender_name, reply_to_email, email_provider, email_footer_html)
VALUES (p_brand_id, p_sender_email, p_sender_name, p_reply_to_email, p_provider, p_footer_html)
ON CONFLICT (brand_id) DO UPDATE SET
default_sender_email = EXCLUDED.default_sender_email,
default_sender_name = EXCLUDED.default_sender_name,
reply_to_email = EXCLUDED.reply_to_email,
email_provider = EXCLUDED.email_provider,
email_footer_html = EXCLUDED.email_footer_html,
updated_at = now()
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.upsert_communication_template(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, UUID);
CREATE OR REPLACE FUNCTION public.upsert_communication_template(
p_id UUID,
p_brand_id UUID,
p_name TEXT,
p_subject TEXT,
p_body_text TEXT,
p_body_html TEXT,
p_template_type TEXT,
p_campaign_type TEXT,
p_created_by UUID
)
RETURNS communication_templates
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result communication_templates;
v_id UUID;
BEGIN
v_id := coalesce(p_id, uuid_generate_v4());
INSERT INTO public.communication_templates
(id, brand_id, name, subject, body_text, body_html, template_type, campaign_type, created_by)
VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_type, p_campaign_type, p_created_by)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
subject = EXCLUDED.subject,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
template_type = EXCLUDED.template_type,
campaign_type = EXCLUDED.campaign_type,
updated_at = now()
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.upsert_communication_campaign(UUID, UUID, TEXT, TEXT, TEXT, TEXT, UUID, TEXT, TEXT, JSONB, TIMESTAMPTZ, UUID);
CREATE OR REPLACE FUNCTION public.upsert_communication_campaign(
p_id UUID,
p_brand_id UUID,
p_name TEXT,
p_subject TEXT,
p_body_text TEXT,
p_body_html TEXT,
p_template_id UUID,
p_campaign_type TEXT,
p_status TEXT,
p_audience_rules JSONB,
p_scheduled_at TIMESTAMPTZ,
p_created_by UUID
)
RETURNS communication_campaigns
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result communication_campaigns;
v_id UUID;
BEGIN
v_id := coalesce(p_id, uuid_generate_v4());
INSERT INTO public.communication_campaigns
(id, brand_id, name, subject, body_text, body_html, template_id, campaign_type, status, audience_rules, scheduled_at, created_by)
VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_id, p_campaign_type, p_status, p_audience_rules, p_scheduled_at, p_created_by)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
subject = EXCLUDED.subject,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
template_id = EXCLUDED.template_id,
campaign_type = EXCLUDED.campaign_type,
status = EXCLUDED.status,
audience_rules = EXCLUDED.audience_rules,
scheduled_at = EXCLUDED.scheduled_at,
updated_at = now()
WHERE communication_campaigns.brand_id = p_brand_id
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.log_communication_messages(JSONB);
CREATE OR REPLACE FUNCTION public.log_communication_messages(p_entries JSONB)
RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_inserted_count integer := 0;
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_entries)
LOOP
INSERT INTO public.communication_message_logs
(brand_id, campaign_id, customer_id, customer_email, delivery_method,
subject, body_preview, status, sent_at, error_message)
VALUES (
(v_entry->>'brand_id')::UUID,
nullif(v_entry->>'campaign_id', '')::UUID,
nullif(v_entry->>'customer_id', '')::UUID,
v_entry->>'customer_email',
v_entry->>'delivery_method',
v_entry->>'subject',
left(v_entry->>'body_preview', 500),
coalesce(v_entry->>'status', 'queued'),
now(),
v_entry->>'error_message'
);
v_inserted_count := v_inserted_count + 1;
END LOOP;
RETURN v_inserted_count;
END;
$$;
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;
RETURN v_result;
END;
$$;
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_customers JSONB;
BEGIN
v_target := p_audience_rules->>'target';
-- Stop-based targeting
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 o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_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 (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
-- ZIP/city targeting (customer ZIP not in orders — city filter only)
ELSIF v_target = 'zip_code' THEN
v_city := p_audience_rules->>'city';
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true)
AND (v_city IS NULL OR true); -- city filter placeholder; ZIP requires customer_zip column in orders
-- 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_orders AS (
SELECT o.customer_id, MIN(o.created_at)
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_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_id HAVING COUNT(*) = 1
)
SELECT COUNT(*),
jsonb_agg(DISTINCT jsonb_build_object('id', fo.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM first_orders fo
JOIN orders o ON o.customer_id = fo.customer_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = fo.customer_id AND cp.brand_id = p_brand_id
WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSIF v_order_hist = 'repeat' THEN
WITH repeat_customer_ids AS (
SELECT o.customer_id
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_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_id HAVING COUNT(*) > 1
)
SELECT COUNT(*),
jsonb_agg(DISTINCT jsonb_build_object('id', rc.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM repeat_customer_ids rc
JOIN orders o ON o.customer_id = rc.customer_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = rc.customer_id AND cp.brand_id = p_brand_id
WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSE
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
END IF;
-- Product-based targeting
ELSIF v_target = 'product' THEN
v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID;
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_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 (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
-- Explicit customer ID list
ELSIF v_target = 'customer_ids' THEN
SELECT COUNT(*),
jsonb_agg(jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE o.customer_id IN (
SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID
)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSE
-- all_customers or unknown
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
END IF;
-- Cap sample at 20
SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_customers
FROM (SELECT * FROM jsonb_array_elements(coalesce(v_customers, '[]'::jsonb)) LIMIT 20) t(x);
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers);
END;
$$;
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_entries JSONB := '[]'::JSONB;
v_customer 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_customer IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', v_campaign.brand_id, 'campaign_id', p_campaign_id,
'customer_id', v_customer->>'id', 'customer_email', v_customer->>'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;
$$;
DROP FUNCTION IF EXISTS public.get_communication_campaigns(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_campaigns(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_campaigns JSONB;
BEGIN
IF p_brand_id IS NULL THEN
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns FROM communication_campaigns c;
ELSE
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns
FROM communication_campaigns c WHERE c.brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('campaigns', v_campaigns);
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_campaign_by_id(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_campaign_by_id(p_campaign_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_campaign communication_campaigns%ROWTYPE;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN RETURN jsonb_build_object('campaign', NULL); END IF;
RETURN jsonb_build_object('campaign', row_to_json(v_campaign));
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_templates(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_templates(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_templates JSONB;
BEGIN
IF p_brand_id IS NULL THEN
SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates FROM communication_templates t;
ELSE
SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates
FROM communication_templates t WHERE t.brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('templates', v_templates);
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_settings(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_settings(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_settings communication_settings%ROWTYPE;
BEGIN
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = p_brand_id;
IF NOT FOUND THEN RETURN jsonb_build_object('settings', NULL); END IF;
RETURN jsonb_build_object('settings', row_to_json(v_settings));
END;
$$;
DROP FUNCTION IF EXISTS public.get_message_logs(UUID, UUID, TEXT, INTEGER);
CREATE OR REPLACE FUNCTION public.get_message_logs(
p_brand_id UUID,
p_campaign_id UUID DEFAULT NULL,
p_status TEXT DEFAULT NULL,
p_limit INTEGER DEFAULT 100
)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_logs JSONB;
BEGIN
SELECT COALESCE(jsonb_agg(l ORDER BY l.created_at DESC), '[]'::JSONB) INTO v_logs
FROM (
SELECT l.* FROM communication_message_logs l
WHERE (p_brand_id IS NULL OR l.brand_id = p_brand_id)
AND (p_campaign_id IS NULL OR l.campaign_id = p_campaign_id)
AND (p_status IS NULL OR l.status = p_status)
ORDER BY l.created_at DESC LIMIT p_limit
) l;
RETURN jsonb_build_object('logs', v_logs);
END;
$$;
DROP FUNCTION IF EXISTS public.delete_communication_campaign(UUID);
CREATE OR REPLACE FUNCTION public.delete_communication_campaign(p_campaign_id UUID)
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE communication_campaigns SET status = 'canceled', updated_at = now()
WHERE id = p_campaign_id;
RETURN FOUND;
END;
$$;
NOTIFY pgrst, 'reload schema';