Files
route-commerce/supabase/migrations/099_harvest_reach_segmentation.sql

554 lines
20 KiB
PL/PgSQL

-- ============================================================
-- 099_harvest_reach_segmentation.sql
-- Advanced segmentation: product filter, multi-filter AND/OR,
-- stop picker, campaign analytics, scheduled campaign sender
-- ============================================================
-- Helper: get all customer IDs matching a single filter block
CREATE OR REPLACE FUNCTION get_customer_ids_for_filter(
p_brand_id UUID,
p_filter_type TEXT,
p_filter_params JSONB
)
RETURNS TABLE (customer_id UUID)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_days_back INTEGER := COALESCE((p_filter_params->>'days_back')::int, 90);
v_date_from TEXT := p_filter_params->>'date_from';
v_date_to TEXT := p_filter_params->>'date_to';
v_stop_id UUID := NULLIF(p_filter_params->>'stop_id', '')::UUID;
v_product_id UUID := NULLIF(p_filter_params->>'product_id', '')::UUID;
v_zip_codes TEXT[] := NULL;
v_city TEXT := p_filter_params->>'city';
v_order_hist TEXT := COALESCE(p_filter_params->>'order_history', 'all');
v_tags TEXT[] := NULL;
BEGIN
-- Parse optional zip_codes array
IF p_filter_params ? 'zip_codes' AND jsonb_typeof(p_filter_params->'zip_codes') = 'array' THEN
v_zip_codes := ARRAY(SELECT jsonb_array_elements_text(p_filter_params->'zip_codes'));
END IF;
-- Parse optional tags array
IF p_filter_params ? 'tags' AND jsonb_typeof(p_filter_params->'tags') = 'array' THEN
v_tags := ARRAY(SELECT jsonb_array_elements_text(p_filter_params->'tags'));
END IF;
CASE p_filter_type
WHEN 'all_customers' THEN
RETURN QUERY
SELECT DISTINCT c.id FROM customers c WHERE c.brand_id = p_brand_id;
WHEN 'stop' THEN
RETURN QUERY
SELECT DISTINCT o.customer_id
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND (v_stop_id IS NULL OR s.id = v_stop_id)
AND (v_date_from IS NULL OR s.date >= v_date_from)
AND (v_date_to IS NULL OR s.date <= v_date_to);
WHEN 'upcoming_stop' THEN
RETURN QUERY
SELECT DISTINCT o.customer_id
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND s.date >= CURRENT_DATE::TEXT
AND (v_stop_id IS NULL OR s.id = v_stop_id);
WHEN 'product' THEN
RETURN QUERY
SELECT DISTINCT oi.order_id
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.brand_id = p_brand_id
AND oi.product_id = v_product_id
AND o.created_at >= NOW() - (v_days_back || ' days')::INTERVAL
AND o.status NOT IN ('canceled');
WHEN 'zip_code' THEN
RETURN QUERY
SELECT DISTINCT o.customer_id
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND (v_zip_codes IS NULL OR s.zip = ANY(v_zip_codes))
AND (v_city IS NULL OR s.city ILIKE v_city);
WHEN 'customer_history' THEN
RETURN QUERY
SELECT oc.customer_id
FROM (
SELECT o.customer_id,
COUNT(*) AS order_count,
MIN(o.created_at) AS first_order_at
FROM orders o
WHERE o.brand_id = p_brand_id
AND o.created_at >= NOW() - (v_days_back || ' days')::INTERVAL
AND o.status NOT IN ('canceled')
GROUP BY o.customer_id
) oc
WHERE CASE v_order_hist
WHEN 'first_order' THEN oc.order_count = 1
WHEN 'repeat' THEN oc.order_count > 1
ELSE true
END;
WHEN 'tags' THEN
RETURN QUERY
SELECT DISTINCT cc.customer_id
FROM communication_contacts cc
WHERE cc.brand_id = p_brand_id
AND cc.tags && v_tags;
ELSE
-- Unknown filter type: return empty
RETURN;
END CASE;
END;
$$;
-- ============================================================
-- preview_campaign_audience_v2
-- Supports single-target (legacy) and multi-filter SegmentRuleV2
-- { combinator: "AND"|"OR", filters: [{ type, params }] }
-- For legacy single-target: { target, ... } (backward compat)
-- Returns { count, sample_customers: [...] }
-- ============================================================
CREATE OR REPLACE FUNCTION 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_count BIGINT;
v_customers JSONB;
v_combinator TEXT := 'AND';
v_filters JSONB;
v_customer_ids UUID[];
v_set UUID[];
v_first BOOLEAN := TRUE;
v_filter JSONB;
v_filter_type TEXT;
v_filter_params JSONB;
BEGIN
v_target := p_audience_rules->>'target';
-- Multi-filter SegmentRuleV2 format
IF p_audience_rules ? 'filters' THEN
v_combinator := COALESCE(p_audience_rules->>'combinator', 'AND');
v_filters := p_audience_rules->'filters';
FOR v_filter IN SELECT * FROM jsonb_array_elements(v_filters)
LOOP
v_filter_type := v_filter->>'type';
v_filter_params := COALESCE(v_filter->'params', '{}'::jsonb);
-- Collect matching customer IDs for this filter
IF v_combinator = 'AND' THEN
-- INTERSECTION: only keep customers who match this AND all previous
IF v_first THEN
v_set := ARRAY(SELECT customer_id FROM get_customer_ids_for_filter(p_brand_id, v_filter_type, v_filter_params));
v_first := FALSE;
ELSE
v_set := ARRAY(
SELECT unnest
FROM unnest(v_set) AS unnest
WHERE unnest = ANY(ARRAY(SELECT customer_id FROM get_customer_ids_for_filter(p_brand_id, v_filter_type, v_filter_params)))
);
END IF;
ELSE
-- UNION: accumulate all unique customers
v_set := array_distinct(v_set || ARRAY(SELECT customer_id FROM get_customer_ids_for_filter(p_brand_id, v_filter_type, v_filter_params)));
v_first := FALSE;
END IF;
END LOOP;
-- Build result from accumulated set
SELECT COUNT(*),
COALESCE(jsonb_agg(sub), '[]'::jsonb)
INTO v_count, v_customers
FROM (
SELECT jsonb_build_object(
'id', c.id,
'email', COALESCE(cc.email, c.primary_email),
'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email),
'tags', COALESCE(cc.tags, '{}'::text[]),
'phone', cc.phone
) AS sub
FROM unnest(v_set) AS uid(customer_id)
JOIN customers c ON c.id = uid.customer_id
LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id
LIMIT 20
) sub;
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers);
END IF;
-- Legacy single-target format (backward compat)
CASE v_target
WHEN 'product' THEN
WITH matching AS (
SELECT DISTINCT o.customer_id
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.brand_id = p_brand_id
AND oi.product_id = NULLIF(p_audience_rules->>'product_id', '')::UUID
AND o.created_at >= NOW() - (COALESCE((p_audience_rules->>'days_back')::int, 90) || ' days')::INTERVAL
AND o.status NOT IN ('canceled')
)
SELECT COUNT(*) INTO v_count FROM matching;
SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers
FROM (
SELECT jsonb_build_object(
'id', c.id,
'email', COALESCE(cc.email, c.primary_email),
'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email),
'tags', COALESCE(cc.tags, '{}'::text[]),
'phone', cc.phone
) AS sub
FROM (SELECT DISTINCT customer_id FROM matching) m
JOIN customers c ON c.id = m.customer_id
LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id
LIMIT 20
) sub;
WHEN 'upcoming_stop' THEN
WITH matching AS (
SELECT DISTINCT o.customer_id
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.brand_id = p_brand_id
AND s.date >= CURRENT_DATE::TEXT
AND o.status NOT IN ('canceled')
AND (NULLIF(p_audience_rules->>'stop_id', '') IS NULL OR s.id = NULLIF(p_audience_rules->>'stop_id', '')::UUID)
)
SELECT COUNT(*) INTO v_count FROM matching;
SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers
FROM (
SELECT jsonb_build_object(
'id', c.id,
'email', COALESCE(cc.email, c.primary_email),
'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email),
'tags', COALESCE(cc.tags, '{}'::text[]),
'phone', cc.phone
) AS sub
FROM (SELECT DISTINCT customer_id FROM matching) m
JOIN customers c ON c.id = m.customer_id
LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id
LIMIT 20
) sub;
WHEN 'stop' THEN
WITH matching AS (
SELECT DISTINCT o.customer_id
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.brand_id = p_brand_id
AND (NULLIF(p_audience_rules->>'stop_id', '') IS NULL OR s.id = NULLIF(p_audience_rules->>'stop_id', '')::UUID)
AND (NULLIF(p_audience_rules->>'date_from', '') IS NULL OR s.date >= p_audience_rules->>'date_from')
AND (NULLIF(p_audience_rules->>'date_to', '') IS NULL OR s.date <= p_audience_rules->>'date_to')
AND o.status NOT IN ('canceled')
)
SELECT COUNT(*) INTO v_count FROM matching;
SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers
FROM (
SELECT jsonb_build_object(
'id', c.id,
'email', COALESCE(cc.email, c.primary_email),
'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email),
'tags', COALESCE(cc.tags, '{}'::text[]),
'phone', cc.phone
) AS sub
FROM (SELECT DISTINCT customer_id FROM matching) m
JOIN customers c ON c.id = m.customer_id
LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id
LIMIT 20
) sub;
WHEN 'zip_code' THEN
DECLARE
v_zips TEXT[] := ARRAY(SELECT jsonb_array_elements_text(p_audience_rules->'zip_codes'));
v_city TEXT := p_audience_rules->>'city';
BEGIN
WITH matching AS (
SELECT DISTINCT o.customer_id
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.brand_id = p_brand_id
AND (v_zips IS NULL OR s.zip = ANY(v_zips))
AND (v_city IS NULL OR s.city ILIKE v_city)
AND o.status NOT IN ('canceled')
)
SELECT COUNT(*) INTO v_count FROM matching;
SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers
FROM (
SELECT jsonb_build_object(
'id', c.id,
'email', COALESCE(cc.email, c.primary_email),
'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email),
'tags', COALESCE(cc.tags, '{}'::text[]),
'phone', cc.phone
) AS sub
FROM (SELECT DISTINCT customer_id FROM matching) m
JOIN customers c ON c.id = m.customer_id
LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id
LIMIT 20
) sub;
END;
WHEN 'customer_history' THEN
DECLARE
v_hist TEXT := COALESCE(p_audience_rules->>'order_history', 'all');
v_days INT := COALESCE((p_audience_rules->>'days_back')::int, 90);
BEGIN
WITH order_counts AS (
SELECT o.customer_id, COUNT(*) AS order_count
FROM orders o
WHERE o.brand_id = p_brand_id
AND o.created_at >= NOW() - (v_days || ' days')::INTERVAL
AND o.status NOT IN ('canceled')
GROUP BY o.customer_id
),
filtered AS (
SELECT customer_id FROM order_counts oc
WHERE CASE v_hist
WHEN 'first_order' THEN oc.order_count = 1
WHEN 'repeat' THEN oc.order_count > 1
ELSE true END
)
SELECT COUNT(*) INTO v_count FROM filtered;
SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers
FROM (
SELECT jsonb_build_object(
'id', c.id,
'email', COALESCE(cc.email, c.primary_email),
'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email),
'tags', COALESCE(cc.tags, '{}'::text[]),
'phone', cc.phone
) AS sub
FROM filtered f
JOIN customers c ON c.id = f.customer_id
LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id
LIMIT 20
) sub;
END;
WHEN 'customer_ids' THEN
DECLARE
v_ids UUID[] := ARRAY(SELECT NULLIF(trim(value), '')::UUID FROM jsonb_array_elements_text(p_audience_rules->'customer_ids'));
BEGIN
SELECT COUNT(*) INTO v_count
FROM unnest(v_ids) AS uid(id)
WHERE uid.id IN (SELECT id FROM customers WHERE brand_id = p_brand_id);
SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers
FROM (
SELECT jsonb_build_object(
'id', c.id,
'email', COALESCE(cc.email, c.primary_email),
'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email),
'tags', COALESCE(cc.tags, '{}'::text[]),
'phone', cc.phone
) AS sub
FROM unnest(v_ids) AS uid(id)
JOIN customers c ON c.id = uid.id
LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id
LIMIT 20
) sub;
END;
ELSE -- 'all_customers' or unknown
SELECT COUNT(*) INTO v_count FROM customers WHERE brand_id = p_brand_id;
SELECT COALESCE(jsonb_agg(sub), '[]'::jsonb) INTO v_customers
FROM (
SELECT jsonb_build_object(
'id', c.id,
'email', COALESCE(cc.email, c.primary_email),
'name', COALESCE(c.first_name || ' ' || c.last_name, c.primary_email),
'tags', COALESCE(cc.tags, '{}'::text[]),
'phone', cc.phone
) AS sub
FROM customers c
LEFT JOIN communication_contacts cc ON cc.customer_id = c.id AND cc.brand_id = p_brand_id
WHERE c.brand_id = p_brand_id
LIMIT 20
) sub;
END CASE;
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers);
END;
$$;
-- ============================================================
-- get_products_for_segment_picker
-- Returns active products for product-filter dropdown
-- ============================================================
CREATE OR REPLACE FUNCTION get_products_for_segment_picker(p_brand_id UUID)
RETURNS TABLE (id UUID, name TEXT, type TEXT, price NUMERIC)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT p.id, p.name, p.type, p.price
FROM products p
WHERE p.brand_id = p_brand_id
AND p.deleted_at IS NULL
AND p.active = true
ORDER BY p.name ASC;
END;
$$;
-- ============================================================
-- get_stops_for_segment_picker
-- Returns stops with is_upcoming / is_past flags
-- ============================================================
CREATE OR REPLACE FUNCTION get_stops_for_segment_picker(
p_brand_id UUID,
p_stop_id UUID DEFAULT NULL
)
RETURNS TABLE (
id UUID,
city TEXT,
state TEXT,
date TEXT,
time TEXT,
location TEXT,
zip TEXT,
is_upcoming BOOLEAN,
is_past BOOLEAN
)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_today TEXT := CURRENT_DATE::TEXT;
BEGIN
RETURN QUERY
SELECT
s.id,
s.city,
s.state,
s.date,
s.time,
s.location,
s.zip,
(s.date >= v_today) AS is_upcoming,
(s.date < v_today) AS is_past
FROM stops s
WHERE s.brand_id = p_brand_id
AND s.deleted_at IS NULL
AND s.active = true
AND s.status = 'active'
AND (p_stop_id IS NULL OR s.id = p_stop_id)
ORDER BY s.date DESC;
END;
$$;
-- ============================================================
-- get_campaign_analytics
-- Aggregates message logs into campaign engagement stats
-- ============================================================
CREATE OR REPLACE FUNCTION get_campaign_analytics(
p_brand_id UUID,
p_campaign_id UUID DEFAULT NULL
)
RETURNS TABLE (
campaign_id UUID,
campaign_name TEXT,
total_sent BIGINT,
total_delivered BIGINT,
total_opened BIGINT,
total_clicked BIGINT,
total_bounced BIGINT,
delivered_rate NUMERIC,
open_rate NUMERIC,
click_rate NUMERIC,
bounce_rate NUMERIC,
sent_at TIMESTAMPTZ
)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT
cc.id AS campaign_id,
cc.name AS campaign_name,
COUNT(CASE WHEN ml.status != 'queued' THEN 1 END)::BIGINT AS total_sent,
COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END)::BIGINT AS total_delivered,
COUNT(CASE WHEN ml.opened_at IS NOT NULL THEN 1 END)::BIGINT AS total_opened,
COUNT(CASE WHEN ml.clicked_at IS NOT NULL THEN 1 END)::BIGINT AS total_clicked,
COUNT(CASE WHEN ml.bounced_at IS NOT NULL THEN 1 END)::BIGINT AS total_bounced,
CASE WHEN COUNT(CASE WHEN ml.status != 'queued' THEN 1 END) > 0
THEN ROUND(100.0
* COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END)
/ COUNT(CASE WHEN ml.status != 'queued' THEN 1 END), 1)
ELSE 0 END AS delivered_rate,
CASE WHEN COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END) > 0
THEN ROUND(100.0
* COUNT(CASE WHEN ml.opened_at IS NOT NULL THEN 1 END)
/ COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END), 1)
ELSE 0 END AS open_rate,
CASE WHEN COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END) > 0
THEN ROUND(100.0
* COUNT(CASE WHEN ml.clicked_at IS NOT NULL THEN 1 END)
/ COUNT(CASE WHEN ml.delivered_at IS NOT NULL THEN 1 END), 1)
ELSE 0 END AS click_rate,
CASE WHEN COUNT(CASE WHEN ml.status != 'queued' THEN 1 END) > 0
THEN ROUND(100.0
* COUNT(CASE WHEN ml.bounced_at IS NOT NULL THEN 1 END)
/ COUNT(CASE WHEN ml.status != 'queued' THEN 1 END), 1)
ELSE 0 END AS bounce_rate,
cc.sent_at
FROM communication_campaigns cc
LEFT JOIN communication_message_logs ml ON ml.campaign_id = cc.id
WHERE cc.brand_id = p_brand_id
AND (p_campaign_id IS NULL OR cc.id = p_campaign_id)
AND cc.status = 'sent'
GROUP BY cc.id, cc.name, cc.sent_at
ORDER BY cc.sent_at DESC NULLS LAST;
END;
$$;
-- ============================================================
-- send_scheduled_campaigns
-- Called by cron job: sends all overdue scheduled campaigns
-- ============================================================
CREATE OR REPLACE FUNCTION send_scheduled_campaigns()
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_result JSONB := '[]'::JSONB;
v_send_result JSONB;
BEGIN
FOR v_campaign IN
SELECT * FROM communication_campaigns
WHERE status = 'scheduled'
AND scheduled_at <= NOW()
LOOP
SELECT send_campaign(v_campaign.id) INTO v_send_result;
v_result := v_result || jsonb_build_array(
jsonb_build_object(
'campaign_id', v_campaign.id,
'campaign_name', v_campaign.name,
'result', v_send_result
)
);
END LOOP;
RETURN jsonb_build_object('results', v_result);
END;
$$;