feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 17s
Deploy to route.crispygoat.com / deploy (push) Failing after 17s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
This commit is contained in:
@@ -0,0 +1,554 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user