Files
route-commerce/supabase/migrations/031_reports_v1_rpcs.sql

337 lines
14 KiB
PL/PgSQL

-- Migration 031: Reports V1 — Operational Reporting RPCs
-- SECURITY DEFINER — bypasses RLS, enforces brand scoping in SQL
-- All functions accept p_brand_id NULL = platform_admin sees all brands
-- brand_admin is filtered at the page level via getAdminUser()
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. get_reports_summary — 10 KPI cards
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_reports_summary(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
-- Base WHERE: always filter by brand if provided, always filter by date
-- Status filter: exclude canceled orders from all revenue/order counts
WITH date_orders AS (
SELECT o.id, o.subtotal, o.status, o.brand_id,
CASE WHEN EXISTS (
SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup'
) THEN true ELSE false END AS has_pickup,
CASE WHEN EXISTS (
SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping'
) THEN true ELSE false END AS has_shipping
FROM orders o
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
),
pickup_orders AS (
SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_pickup
),
shipping_orders AS (
SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_shipping
),
pending_pickups AS (
SELECT COUNT(DISTINCT o.id) AS cnt
FROM date_orders o
WHERE o.has_pickup AND o.status = 'pending'
),
completed_pickups AS (
SELECT COUNT(DISTINCT o.id) AS cnt
FROM date_orders o
WHERE o.has_pickup AND o.status = 'completed'
),
contacts_added AS (
SELECT COUNT(*) AS cnt
FROM communication_contacts cc
WHERE cc.created_at::DATE BETWEEN p_start_date AND p_end_date
AND cc.source = 'import'
AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
),
campaigns_sent AS (
SELECT COUNT(*) AS cnt
FROM communication_campaigns cc
WHERE cc.sent_at::DATE BETWEEN p_start_date AND p_end_date
AND cc.status = 'sent'
AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
),
messages_logged AS (
SELECT COUNT(*) AS cnt
FROM communication_message_logs ml
WHERE ml.sent_at::DATE BETWEEN p_start_date AND p_end_date
AND (p_brand_id IS NULL OR ml.brand_id = p_brand_id)
)
SELECT jsonb_build_object(
'gross_sales', COALESCE(SUM(subtotal), 0),
'total_orders', COUNT(DISTINCT id),
'avg_order_value', CASE WHEN COUNT(id) > 0 THEN ROUND(AVG(subtotal), 2) ELSE 0 END,
'pickup_orders', COALESCE((SELECT cnt FROM pickup_orders), 0),
'shipping_orders', COALESCE((SELECT cnt FROM shipping_orders), 0),
'pending_pickups', COALESCE((SELECT cnt FROM pending_pickups), 0),
'completed_pickups', COALESCE((SELECT cnt FROM completed_pickups), 0),
'contacts_added', COALESCE((SELECT cnt FROM contacts_added), 0),
'campaigns_sent', COALESCE((SELECT cnt FROM campaigns_sent), 0),
'messages_logged', COALESCE((SELECT cnt FROM messages_logged), 0)
) INTO v_result
FROM date_orders;
RETURN v_result;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. get_orders_by_stop_report — orders grouped by stop
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_orders_by_stop_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'stop_name', r.stop_name,
'city', r.city,
'state', r.state,
'date', r.date,
'order_count', r.order_count,
'gross_sales', r.gross_sales,
'pending_count', r.pending_count,
'completed_count', r.completed_count
) ORDER BY r.date DESC
), '[]'::JSONB)
FROM (
SELECT
s.city || ', ' || s.state AS stop_name,
s.city,
s.state,
s.date,
COUNT(DISTINCT o.id) AS order_count,
COALESCE(SUM(o.subtotal), 0) AS gross_sales,
COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending_count,
COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed_count
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
GROUP BY s.id, s.city, s.state, s.date
) r;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. get_sales_by_product_report — product revenue
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_sales_by_product_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'product_name', r.product_name,
'units_sold', r.units_sold,
'gross_revenue', r.gross_revenue,
'avg_price', r.avg_price
) ORDER BY r.gross_revenue DESC
), '[]'::JSONB)
FROM (
SELECT
p.name AS product_name,
SUM(oi.quantity) AS units_sold,
SUM(oi.price * oi.quantity) AS gross_revenue,
ROUND(AVG(oi.price), 2) AS avg_price
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
GROUP BY p.id, p.name
) r;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. get_fulfillment_report — pickup / shipping / mixed breakdown
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_fulfillment_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_total NUMERIC;
BEGIN
SELECT COALESCE(SUM(o.subtotal), 0) INTO v_total
FROM orders o
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id);
RETURN COALESCE(jsonb_agg(sub ORDER BY revenue DESC), '[]'::JSONB)
FROM (
SELECT
CASE
WHEN has_pickup AND has_shipping THEN 'mixed'
WHEN has_pickup THEN 'pickup'
ELSE 'shipping'
END AS fulfillment_type,
COUNT(DISTINCT o.id) AS order_count,
SUM(o.subtotal) AS revenue,
CASE WHEN v_total > 0 THEN ROUND(100.0 * SUM(o.subtotal) / v_total, 1) ELSE 0 END AS pct_of_total
FROM (
SELECT o.id, o.subtotal,
EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup') AS has_pickup,
EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping') AS has_shipping
FROM orders o
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
) o
GROUP BY fulfillment_type
) sub;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. get_pickup_status_by_stop — per-stop pickup status
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_pickup_status_by_stop(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'stop_name', r.stop_name,
'city', r.city,
'date', r.date,
'total_orders', r.total_orders,
'pending', r.pending,
'completed', r.completed,
'canceled', r.canceled
) ORDER BY r.date DESC
), '[]'::JSONB)
FROM (
SELECT
s.city || ', ' || s.state AS stop_name,
s.city,
s.date,
COUNT(DISTINCT o.id) AS total_orders,
COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending,
COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed,
COUNT(DISTINCT CASE WHEN o.status = 'canceled' THEN o.id END) AS canceled
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup')
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
GROUP BY s.id, s.city, s.state, s.date
) r;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. get_contact_growth_report — new contacts over time
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_contact_growth_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'date', r.date,
'new_contacts', r.new_contacts,
'imports', r.imports,
'total', r.total
) ORDER BY r.date DESC
), '[]'::JSONB)
FROM (
SELECT
d::DATE AS date,
COALESCE(SUM(CASE WHEN cc.source IS DISTINCT FROM 'import' THEN 1 ELSE 0 END), 0) AS new_contacts,
COALESCE(SUM(CASE WHEN cc.source = 'import' THEN 1 ELSE 0 END), 0) AS imports,
COUNT(cc.*) AS total
FROM generate_series(p_start_date, p_end_date, '1 day'::INTERVAL) d
LEFT JOIN communication_contacts cc
ON cc.created_at::DATE = d::DATE
AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
GROUP BY d::DATE
) r;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. get_campaign_activity_report — campaign status and message counts
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_campaign_activity_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'campaign_name', c.name,
'status', c.status,
'campaign_type', c.campaign_type,
'sent_at', c.sent_at,
'messages_logged', COALESCE(ml.sent_count, 0)
) ORDER BY c.sent_at DESC NULLS LAST
), '[]'::JSONB)
FROM communication_campaigns c
LEFT JOIN (
SELECT campaign_id, COUNT(*) AS sent_count
FROM communication_message_logs
WHERE sent_at::DATE BETWEEN p_start_date AND p_end_date
GROUP BY campaign_id
) ml ON ml.campaign_id = c.id
WHERE c.created_at::DATE BETWEEN p_start_date AND p_end_date
AND (p_brand_id IS NULL OR c.brand_id = p_brand_id);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';